Агент v14: список вложений в refresh, команда fetch_attachment (#43) #44

Merged
aep merged 8 commits from feat/43-attachment-fetch into main 2026-10-22 15:05:58 +03:00
14 changed files with 576 additions and 21 deletions

View File

@ -8,10 +8,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| Раздел | Классы и функции | Ответственность | Что использует | | Раздел | Классы и функции | Ответственность | Что использует |
|---|---|---|---| |---|---|---|---|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` | | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |

View File

@ -20,7 +20,7 @@
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`, выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`). `cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует. - `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения). - Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, `ensure_attachment`, где у хаба берётся файл вложения, и `fetch_attachment`, где скачанный из Jira файл отдаётся хабу).
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira. `Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. - `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
- Подробнее: `ARCHITECTURE.md`. - Подробнее: `ARCHITECTURE.md`.
@ -39,7 +39,7 @@
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`), - Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`), `test_refresh.py` (`refresh`), `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`),
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429), `test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки). `test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
@ -48,7 +48,7 @@
## Первая подсистема ## Первая подсистема
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`. журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`. Последний change — `../jira-hub/openspec/changes/attachment-fetch/` (агент v14: список вложений в `refresh`, команда `fetch_attachment`; решения агента — `openspec/changes/agent-v14-attachment-fetch/design.md`), план — `docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md`.
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
## Выкатка новой версии ## Выкатка новой версии

View File

@ -174,7 +174,8 @@ python3 agent.py prune
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | | `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `<base_url>/secure/attachment/<id>/<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
Правила работы: Правила работы:
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
@ -192,7 +193,7 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
@ -205,6 +206,9 @@ python3 agent.py prune
выкатывается только после v12 (design хаба `projects`, решение 14). выкатывается только после v12 (design хаба `projects`, решение 14).
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`). загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`);
перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на
`fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»).
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.

View File

@ -0,0 +1,92 @@
# Вложения из Jira по запросу (агент v14) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** агент v14 в `refresh` присылает список вложений задач (без содержимого) и выполняет новую команду
`fetch_attachment`: скачивает файл вложения из Jira и передаёт хабу `POST /agent/files`.
**Architecture:** список — поле `attachment` в `search` и `Executor.attachments_of(raw)` в `to_state`; скачивание —
`JiraClient.download` (байты, предел, без перенаправлений на другой хост) и `Executor.do_fetch_attachment`; отправка —
`HubClient.put_file` (работает и через `FolderTransport`). Python 3.9, только stdlib.
**Tech Stack:** Python 3.9 (`/usr/bin/python3`), stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`.
**Spec:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/attach-fetch/openspec/changes/attachment-fetch/` —
`design.md` (решения 1, 4–6, 8, 17, 18), `specs/agent/spec.md` («Чтение состояния», «Скачивание вложения»),
`specs/commands/spec.md` («Скачивание вложения по запросу»), `specs/hub-api/spec.md` (`POST /agent/files`). Решения агента —
`openspec/changes/agent-v14-attachment-fetch/design.md` этого репозитория (таблица `R-n`).
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/attach-fetch`, ветка `feat/43-attachment-fetch`
(aep/jira-agent#43). Все команды — из корня worktree.
## Global Constraints
- `AGENT_VERSION = 14`.
- Python 3.9 stdlib; без `match`, без `X | Y` в аннотациях (`tests/test_py39.py`).
- Предел — `MAX_ATTACHMENT = 10 * 1024 * 1024` (10 485 760 байт).
- Адрес содержимого — `<base_url>/secure/attachment/<id>/<urllib.parse.quote(filename, safe="")>`; `content` из ответа Jira не используется.
- Таймаут скачивания — `max(self.timeout, 120)`.
- Элемент `attachments`: `{id, filename, size, mime_type, author, created}` (`author` — `displayName`, иначе `name`, иначе `None`).
- Тексты ошибок (точно): «вложение #<id> удалено из <key> в Jira»; «файл больше 10 МБ (<размер>) — скачайте в Jira» (неизвестный размер — «больше 10,0 МБ»); «Jira перенаправила скачивание на <хост>»; «файл пришёл не целиком (<получено> из <размер>)». Размер — «N Б» / «N,N КБ» / «N,N МБ» по 1024, запятая.
- Содержимое файла не попадает в лог, журнал и текст ошибок.
- Каждый коммит — с `(#43)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
- Решения вне спеки и плана — в таблицу `openspec/changes/agent-v14-attachment-fetch/design.md`; находки ревью задачи n — `T<n>.<k>`.
## Review Focus
1. Перенаправление 302 на тот же хост (Jira иногда так отдаёт файл) — проходит; на другой хост или схему — `failed` без запроса туда (тесты в Task 2).
2. Вложение без `size` в Jira и содержимое больше предела — чтение обрывается, хабу ничего не уходит (тест в Task 3).
3. Пустое вложение (0 байт) — `done`, хаб получил пустой `content` (тест в Task 3).
4. Хаб недоступен на `POST /agent/files` — `failed` с `retryable`, как прочие обрывы связи с хабом (тест в Task 3).
5. Кириллица и пробелы в имени — путь скачивания в URL-кодировке, фейк Jira находит файл (тест в Task 3).
---
### Task 1: Список вложений в состоянии и версия 14
**Files:**
- Modify: `src/agent.py` — `AGENT_VERSION = 14`, `STATE_FIELDS` + `"attachment"`, `Executor.attachment_state(a) -> dict` (статический), `Executor.attachments_of(raw) -> List[dict]`, `to_state` добавляет `attachments`
- Test: `tests/test_refresh.py`
**Interfaces:**
- Produces: `attachments_of(raw)` — вложения в порядке Jira, без `id` — отброшено с `log` (ключ задачи); нет поля `attachment` — `[]`.
- [ ] **Step 1: Тесты** — `test_refresh_attachments_in_state` (сценарий «Вложения в состоянии» из дельты `agent`: два вложения, `author = "Потапова Анна"`, `size = 240000`, `mime_type = "image/png"`; число вызовов `search` прежнее; к содержимому обращений нет — фейк считает GET `/secure/attachment/`), `test_refresh_attachment_without_id_dropped`, `test_refresh_no_attachments_empty_list`.
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL.
- [ ] **Step 3: Реализация** по сигнатурам.
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент v14: список вложений в состоянии refresh (#43)`.
### Task 2: Скачивание байтов из Jira и файл хабу
**Files:**
- Modify: `src/agent.py` — `Http.fetch_bytes(path, limit, timeout) -> bytes` (GET, без разбора JSON; перенаправления — свой `HTTPRedirectHandler`: тот же схема+хост+порт, что у `base`, — идти; иначе `RedirectAway(host)`); `JiraClient.download(path, limit) -> bytes` (через `_retry`, счётчик вызовов); класс `RedirectAway(Exception)` с полем `host`; `TooBig(Exception)`; `HubClient.put_file(data: bytes) -> dict` (`POST /agent/files {content: base64}`)
- Modify: `tests/fake_jira.py` — `GET /secure/attachment/<id>/<имя>` отдаёт `data` вложения (имя сверяется после раскодирования), счётчик `self.content_gets`; `self.redirect_content = None` (адрес для 302); `self.hide_size = set()` (id без `size` в JSON)
- Modify: `tests/fake_hub.py` — `POST /agent/files` кладёт в `self.files`, отвечает `{file, size}`; `self.reject_files = None` (код ответа или `"drop"`)
- Test: `tests/test_clients.py`
**Interfaces:**
- Produces: `JiraClient.download(path: str, limit: int) -> bytes` — читает не больше `limit + 1` байт; если прочитано больше `limit` — `TooBig`; 429/503 — повтор как у `call`; `RedirectAway` и `TooBig` наружу.
- [ ] **Step 1: Тесты** — `test_download_bytes` (тело как есть, `calls` +1, заголовок `Authorization` ушёл); `test_download_redirect_same_host` (302 на тот же хост → байты); `test_download_redirect_other_host` (302 на `http://127.0.0.2:<порт>/x` → `RedirectAway`, второй сервер запроса не получил); `test_download_limit` (limit=10, тело 20 байт → `TooBig`); `test_download_429_retry`; `test_put_file_http` и `test_put_file_folder` (через `FolderTransport` и `tests/fake_relay.py`).
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_clients -q` → FAIL.
- [ ] **Step 3: Реализация.** Обработчик перенаправлений — `urllib.request.build_opener(_SameHostRedirect(base), HTTPSHandler(context=ctx))`; `urlopen` в `Http.send` не трогать.
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент: скачивание байтов из Jira без чужих перенаправлений, файл хабу (#43)`.
### Task 3: Команда `fetch_attachment`
**Files:**
- Modify: `src/agent.py` — `Executor.do_fetch_attachment(p, cmd) -> dict`, `_fmt_size(n: Optional[int]) -> str`; ARCHITECTURE.md (раздел «Исполнитель», `JiraClient.download`, `HubClient.put_file`), `CLAUDE.md` (граница `Executor` знает хаб и в `fetch_attachment`; последний change)
- Test: `tests/test_executor_attachments.py`
**Interfaces:**
- Consumes: `JiraClient.download`, `RedirectAway`, `TooBig`, `HubClient.put_file` (Task 2); `self._hub(fn, *args)` для вызова хаба.
- Produces: результат `done` — `{"attachment_id", "file", "size"}`; `file` и `size` — из ответа хаба.
- [ ] **Step 1: Тесты** — `test_fetch_screenshot` (сценарий «Скачивание скриншота»: имя «скрин 1.png», фейк Jira отдал файл, хаб получил те же байты, результат `done` с sha256 и размером, в журнале агента нет содержимого); `test_fetch_deleted` («вложение #31337 удалено из AURORA-1650 в Jira», содержимое не запрашивалось); `test_fetch_too_large_known` (size 25 000 000 в JSON → «файл больше 10 МБ (23,8 МБ) — скачайте в Jira», `content_gets == 0`); `test_fetch_too_large_unknown` (`hide_size`, содержимое 12 МБ → «файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira», хаб ничего не получил); `test_fetch_redirect_away` («Jira перенаправила скачивание на 127.0.0.2»); `test_fetch_truncated` (фейк отдаёт меньше байт, чем `size` → «файл пришёл не целиком (N из M)»); `test_fetch_empty` (0 байт → `done`, `size = 0`); `test_fetch_hub_down` (`reject_files = "drop"` → `failed`, `retryable`, `peer = "хабом"`); `test_fetch_hub_422` (ошибка с «хаб: »).
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_attachments -q` → FAIL.
- [ ] **Step 3: Реализация** по контракту `commands` «Скачивание вложения по запросу» (шаги 1–5); лог — `log(" вложение #%s «%s» (%s) ← %s" % (...))`.
- [ ] **Step 4: Прогон** — весь набор с `-W error::ResourceWarning` → PASS.
- [ ] **Step 5: Commit** — `Агент v14: команда fetch_attachment (#43)`.

View File

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

View File

@ -0,0 +1,21 @@
# agent-v14-attachment-fetch — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/attachment-fetch/` (issue aep/jira-hub#83, aep/jira-agent#43).
Агентская часть — решения 1, 4–6, 8, 17, 18 дизайна хаба; требования `agent` «Чтение состояния» (MODIFIED: поле
`attachments` в состоянии, `attachment` в `search`) и «Скачивание вложения» (ADDED); контракт команды — `commands`
«Скачивание вложения по запросу» (`fetch_attachment`); отправка файла — `hub-api` «Маршруты агента»
(`POST /agent/files`). Версия агента — 14.
## Decisions made without discussion
Решения хаба записаны в `jira-hub/openspec/changes/attachment-fetch/design.md` (раздел «Decisions made without
discussion»); здесь — только решения агентской части, принятые при исполнении.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-50 | 2026-10-22 | Вложение без `filename` в ответе Jira отбрасывается с логом, как без `id` | Хаб требует непустой `filename` (`hub-api`), иначе 422 на всю пачку состояния | Такое вложение не видно в мосту |
| R-51 | 2026-10-22 | `size` вложения не целое, отрицательное или `bool` — передаётся `null` | Хаб принимает только неотрицательное целое или `null` | Нет |
| R-52 | 2026-10-22 | Отправка файла хабу — `HubClient.upload_file` (в плане — `put_file`) | У `FakeHub` уже есть хелпер `put_file`; одно имя на разные действия путает | Нет |
| R-53 | 2026-10-22 | Тесты `upload_file` через папку — в `tests/test_folder.py` | Там фикстура реле | Нет |
| R-54 | 2026-10-22 | По M-4 финального ревью: запрет перенаправлений на другой адрес — для всех запросов агента (`Http.send` и `fetch_bytes` через `_opener`), а не только для скачивания; отказ в JSON-вызове — `HttpError 302` «перенаправление на <хост> отклонено» | Design хаба #6: учётные данные уходят только на `base_url`; `urllib` переносил `Authorization` на чужой хост в любом вызове | Если Jira банка штатно перенаправляет API на другой хост — вызовы падают с понятной ошибкой, видно на живой проверке |
| R-55 | 2026-10-22 | По M-1: при неизвестном размере тело короче `Content-Length` — `ConnectionError` (повтор), а не файл | Иначе битый файл становится «скачанным» у хаба | Нет |

View File

@ -33,7 +33,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 13 AGENT_VERSION = 14
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@ -118,6 +118,35 @@ class HttpError(Exception):
self.code, self.text, self.headers = code, text, headers or {} self.code, self.text, self.headers = code, text, headers or {}
class RedirectAway(Exception):
"""Сервер перенаправил запрос на другой адрес (схема, хост или порт): учётные данные туда не отправляются."""
def __init__(self, host: str):
super().__init__("перенаправление на %s" % host)
self.host = host
class TooBig(Exception):
"""Ответ длиннее предела: чтение оборвано."""
def _origin(url: str) -> Tuple[str, str, Optional[int]]:
u = urllib.parse.urlsplit(url)
scheme = (u.scheme or "").lower()
return scheme, (u.hostname or "").lower(), u.port or {"http": 80, "https": 443}.get(scheme)
class _SameOriginRedirect(urllib.request.HTTPRedirectHandler):
"""Перенаправления — только в пределах адреса клиента: urllib переносит Authorization и на чужой хост."""
def __init__(self, base: str):
self.origin = _origin(base)
def redirect_request(self, req, fp, code, msg, headers, newurl):
if _origin(newurl) != self.origin:
fp.close()
raise RedirectAway(urllib.parse.urlsplit(newurl).hostname or newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
class Http: class Http:
def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None): def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None):
self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx
@ -143,9 +172,12 @@ class Http:
if content_type is not None: if content_type is not None:
req.add_header("Content-Type", content_type) req.add_header("Content-Type", content_type)
try: try:
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: with self._opener().open(req, timeout=timeout or self.timeout) as resp:
raw = resp.read() raw = resp.read()
status = resp.status status = resp.status
except RedirectAway as e:
self._touch()
raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
self._touch() self._touch()
try: try:
@ -164,6 +196,47 @@ class Http:
except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None
def _opener(self):
"""Открыватель запросов: перенаправления — только на адрес клиента (urllib переносит Authorization и на
чужой хост); контекст TLS — из настроек."""
handlers = [_SameOriginRedirect(self.base)] # type: List[Any]
if self.ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=self.ctx))
return urllib.request.build_opener(*handlers)
def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes:
"""GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее
limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send."""
req = urllib.request.Request(self.base + path, method="GET")
for k, v in self.headers.items():
req.add_header(k, v)
req.add_header("Accept", "*/*")
try:
with self._opener().open(req, timeout=timeout or self.timeout) as resp:
data = resp.read(limit + 1)
rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше
if rest and len(data) <= limit:
raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest))
except RedirectAway:
self._touch()
raise
except urllib.error.HTTPError as e:
self._touch()
try:
headers = {k.lower(): v for k, v in e.headers.items()}
text = e.read().decode("utf-8", "replace")
except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err:
raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None
finally:
e.close()
raise HttpError(e.code, text, headers) from None
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e:
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
self._touch()
if len(data) > limit:
raise TooBig("ответ длиннее %d байт" % limit)
return data
def _touch(self) -> None: def _touch(self) -> None:
"""Вызывается на каждый полученный HTTP-ответ (любой код).""" """Вызывается на каждый полученный HTTP-ответ (любой код)."""
@ -236,6 +309,10 @@ class JiraClient(Http):
"multipart/form-data; boundary=" + boundary, "multipart/form-data; boundary=" + boundary,
timeout=max(self.timeout, 120))) timeout=max(self.timeout, 120)))
def download(self, path: str, limit: int) -> bytes:
"""Содержимое вложения (байты), таймаут не меньше 120 с, 429/503 — повтор как у call. Не логируется."""
return self._retry(lambda: self.fetch_bytes(path, limit, max(self.timeout, 120)))
def get(self, path, **params): def get(self, path, **params):
return self.call("GET", path, params=params or None) return self.call("GET", path, params=params or None)
@ -370,6 +447,10 @@ class HubClient(Http):
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file.""" """Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
return self.call("GET", "/agent/files/%s" % sha256) return self.call("GET", "/agent/files/%s" % sha256)
def upload_file(self, data: bytes) -> dict:
"""Файл вложения, скачанный из Jira, — в хранилище хаба: {file (sha256), size}."""
return self.call("POST", "/agent/files", {"content": base64.b64encode(data).decode("ascii")})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Журнал: только дозапись; started → finished → acked по id команды # Журнал: только дозапись; started → finished → acked по id команды
@ -574,6 +655,18 @@ class HubError(Exception):
FILE_GONE = "файл удалён с хаба, загрузите заново" FILE_GONE = "файл удалён с хаба, загрузите заново"
MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт)
def _fmt_size(n: Optional[int]) -> str:
"""Размер для текстов ошибок: «N Б», «N,N КБ», «N,N МБ» (по 1024, запятая), как у хаба."""
if n is None:
return "размер неизвестен"
if n < 1024:
return "%d Б" % n
if n < 1024 * 1024:
return ("%.1f КБ" % (n / 1024)).replace(".", ",")
return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",")
class Executor: class Executor:
@ -939,9 +1032,35 @@ class Executor:
return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку") return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку")
return self._out("done", {"attachment_id": str(first["id"]), "created": True}) return self._out("done", {"attachment_id": str(first["id"]), "created": True})
def do_fetch_attachment(self, p: dict, cmd: dict) -> dict:
"""Файл вложения из Jira — хабу (POST /agent/files). В Jira не пишет; адрес содержимого собирается из base_url,
перенаправление на другой адрес — отказ (учётные данные туда не уходят). Содержимое не логируется."""
key, aid = p["key"], str(p["attachment_id"])
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
found = [a for a in (issue.get("fields") or {}).get("attachment") or [] if str(a.get("id")) == aid]
if not found:
return self._out("failed", error="вложение #%s удалено из %s в Jira" % (aid, key))
filename, size = found[0].get("filename") or "", found[0].get("size")
size = size if isinstance(size, int) and not isinstance(size, bool) else None
too_big = "файл больше 10 МБ (%s) — скачайте в Jira"
if size is not None and size > MAX_ATTACHMENT:
return self._out("failed", error=too_big % _fmt_size(size))
try:
data = self.jira.download("/secure/attachment/%s/%s" % (aid, urllib.parse.quote(filename, safe="")),
MAX_ATTACHMENT)
except TooBig:
return self._out("failed", error=too_big % ("больше " + _fmt_size(MAX_ATTACHMENT)))
except RedirectAway as e:
return self._out("failed", error="Jira перенаправила скачивание на %s" % e.host)
if size is not None and len(data) != size:
return self._out("failed", error="файл пришёл не целиком (%d из %d)" % (len(data), size))
log(" вложение #%s «%s» (%s) ← %s" % (aid, filename, _fmt_size(len(data)), key))
got = self._hub(self.hub.upload_file, data)
return self._out("done", {"attachment_id": aid, "file": got.get("file"), "size": got.get("size")})
# --- refresh --------------------------------------------------------------- # --- refresh ---------------------------------------------------------------
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
"issuetype", "created", "updated", "issuelinks", "comment"] "issuetype", "created", "updated", "issuelinks", "comment", "attachment"]
def state_fields(self) -> str: def state_fields(self) -> str:
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
@ -995,7 +1114,7 @@ class Executor:
"planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
"planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
"epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
"created": f.get("created"), "jira_updated": f.get("updated")} "created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)}
def links_from(self, raw: dict) -> List[dict]: def links_from(self, raw: dict) -> List[dict]:
key, out = raw["key"], [] key, out = raw["key"], []
@ -1044,6 +1163,27 @@ class Executor:
return {"id": str(c.get("id")), "author": a.get("displayName") or a.get("name") or None, return {"id": str(c.get("id")), "author": a.get("displayName") or a.get("name") or None,
"created": c.get("created"), "updated": c.get("updated"), "body": c.get("body") or ""} "created": c.get("created"), "updated": c.get("updated"), "body": c.get("body") or ""}
@staticmethod
def attachment_state(a: dict) -> dict:
"""Вложение для хаба — без содержимого: автор как у комментария, размер — целое или None, время Jira как есть."""
u = a.get("author") or {}
size = a.get("size")
return {"id": str(a.get("id")), "filename": a.get("filename") or "",
"size": size if isinstance(size, int) and not isinstance(size, bool) and size >= 0 else None,
"mime_type": a.get("mimeType") or None, "author": u.get("displayName") or u.get("name") or None,
"created": a.get("created")}
def attachments_of(self, raw: dict) -> List[dict]:
"""Вложения задачи из ответа search (поле attachment отдаётся целиком). Нет поля — []; вложение без id или без
имени отбрасывается с записью в лог: хаб такую пачку не примет."""
out = []
for a in (raw.get("fields") or {}).get("attachment") or []:
if not isinstance(a, dict) or not a.get("id") or not a.get("filename"):
log(" ! %s: вложение без id или имени пропущено" % raw.get("key"))
continue
out.append(self.attachment_state(a))
return out
def comments_of(self, raw: dict) -> Optional[List[dict]]: def comments_of(self, raw: dict) -> Optional[List[dict]]:
"""Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично
через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30); через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30);

View File

@ -32,6 +32,8 @@ class FakeHub:
self.histories = [] # тела POST /agent/history self.histories = [] # тела POST /agent/history
self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа
self.posts = [] # порядок POST /agent/state и /agent/history self.posts = [] # порядок POST /agent/state и /agent/history
self.uploaded = [] # содержимое, присланное агентом POST /agent/files
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа
self.lock = threading.Lock() self.lock = threading.Lock()
hub = self hub = self
@ -85,6 +87,10 @@ class FakeHub:
if h.headers.get("Authorization") != "Bearer " + self.token: if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"}) return self._send(h, 401, {"error": "unauthorized"})
path = h.path.split("?")[0] path = h.path.split("?")[0]
if path == "/agent/files" and method == "POST" and self.reject_files == "drop":
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR)
return
if path == "/agent/history" and self.reject_history == "drop": if path == "/agent/history" and self.reject_history == "drop":
h.close_connection = True h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит
@ -141,6 +147,12 @@ class FakeHub:
self.posts.append(path) self.posts.append(path)
self.states.append(body) self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
if path == "/agent/files" and method == "POST":
if self.reject_files:
return self.reject_files
data = base64.b64decode(body.get("content") or "", validate=True)
self.uploaded.append(data)
return 200, {"file": self.put_file(data), "size": len(data)}
m = re.match(r"^/agent/files/([^/]+)$", path) m = re.match(r"^/agent/files/([^/]+)$", path)
if m and method == "GET": if m and method == "GET":
self.file_requests += 1 self.file_requests += 1

View File

@ -6,7 +6,7 @@ import socket
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, quote, unquote, urlparse
MSK = timezone(timedelta(hours=3)) MSK = timezone(timedelta(hours=3))
@ -85,6 +85,13 @@ class FakeJira:
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
self.bad_attachment_response = False # ответить на загрузку пустым списком self.bad_attachment_response = False # ответить на загрузку пустым списком
self.hide_size = set() # id вложений без size в JSON (Jira не отдала размер)
self.hide_attachment_field = False # поле attachment скрыто настройками Jira
self.content_gets = 0 # GET /secure/attachment/<id>/<имя> — запросы содержимого
self.redirect_content = None # адрес для 302 на запрос содержимого (абсолютный или путь этого сервера)
self.short_content = set() # id вложений, содержимое которых отдаётся без последнего байта
self.lying_length = set() # id вложений: Content-Length больше тела, соединение рвётся после тела
self.redirects = {} # путь → адрес Location: 302 вместо ответа (любой метод)
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
@ -162,15 +169,23 @@ class FakeJira:
self.comments.setdefault(key, []).append(c) self.comments.setdefault(key, []).append(c)
return c return c
def add_attachment(self, key, filename, data, mime="application/octet-stream"): def add_attachment(self, key, filename, data, mime="application/octet-stream", name="potapova", display="Потапова Анна",
created="2026-10-20T09:15:00.000+0300"):
self.attachment_seq += 1 self.attachment_seq += 1
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data} author = {"name": name, "displayName": display} if display else ({"name": name} if name else None)
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data,
"author": author, "created": created}
self.attachments.setdefault(key, []).append(a) self.attachments.setdefault(key, []).append(a)
return a return a
def attachment_json(self, a): def attachment_json(self, a):
return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"], out = {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"],
"filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"]} "filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"], "author": a.get("author"),
"created": a.get("created"),
"content": "%s/secure/attachment/%s/%s" % (self.base_url, a["id"], quote(a["filename"]))}
if a["id"] in self.hide_size:
del out["size"]
return out
def writes(self): def writes(self):
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]
@ -193,7 +208,7 @@ class FakeJira:
shown = lst if cap is None else lst[:cap] shown = lst if cap is None else lst[:cap]
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
"comments": shown} "comments": shown}
if fields is None or "attachment" in fields: if (fields is None or "attachment" in fields) and not self.hide_attachment_field:
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])] out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
return {"key": key, "id": f["id"], "fields": out} return {"key": key, "id": f["id"], "fields": out}
@ -250,6 +265,14 @@ class FakeJira:
code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]}
except Exception as e: # некорректный запрос к фейку except Exception as e: # некорректный запрос к фейку
code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]} code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]}
if url.path in self.redirects:
h.send_response(302)
h.send_header("Location", self.redirects[url.path])
h.send_header("Content-Length", "0")
h.end_headers()
return
if method == "GET" and (url.path.startswith("/secure/attachment/") or url.path.startswith("/secure/moved/")):
return self._content(h, url.path)
if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue": if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue":
self.drop_next_post_issue = False self.drop_next_post_issue = False
h.close_connection = True h.close_connection = True
@ -262,6 +285,38 @@ class FakeJira:
return return
self._send(h, code, payload) self._send(h, code, payload)
def _content(self, h, path):
"""Содержимое вложения, как /secure/attachment/<id>/<имя> Jira: байты, не JSON. /secure/moved/… — цель 302."""
with self.lock:
m = re.match(r"^/secure/(attachment|moved)/(\d+)/(.+)$", path)
found = None
if m:
for lst in self.attachments.values():
for a in lst:
if a["id"] == m.group(2) and a["filename"] == unquote(m.group(3)):
found = a
redirect = self.redirect_content if m and m.group(1) == "attachment" else None
if found is not None and redirect is None:
self.content_gets += 1
if redirect is not None:
h.send_response(302)
h.send_header("Location", redirect)
h.send_header("Content-Length", "0")
h.end_headers()
return
if found is None:
return self._send(h, 404, {"errorMessages": ["нет вложения"]})
data = found["data"][:-1] if found["id"] in self.short_content else found["data"]
h.send_response(200)
h.send_header("Content-Type", found["mimeType"])
h.send_header("Content-Length", str(len(data) + (60 if found["id"] in self.lying_length else 0)))
h.end_headers()
h.wfile.write(data)
if found["id"] in self.lying_length:
h.wfile.flush()
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # обрыв посреди тела
def _send(self, h, code, payload, headers=None): def _send(self, h, code, payload, headers=None):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""
h.send_response(code) h.send_response(code)

View File

@ -90,6 +90,70 @@ class ClientsTest(unittest.TestCase):
self.jira.upload("AURORA-1", "a.txt", b"abc") self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с
def _content_path(self, a):
return "/secure/attachment/%s/%s" % (a["id"], urllib.parse.quote(a["filename"], safe=""))
def test_download_bytes(self):
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "скрин 1.png", b"\x89PNG\x00\x01", mime="image/png")
data = self.jira.download(self._content_path(a), 100)
self.assertEqual(data, b"\x89PNG\x00\x01")
self.assertEqual(self.jira.calls, 1)
self.assertTrue(self.fake.last_headers.get("authorization", "").startswith("Basic "))
self.assertEqual(self.fake.content_gets, 1)
def test_download_redirect_same_host(self):
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "a.bin", b"abc")
self.fake.redirect_content = "%s/secure/moved/%s/a.bin" % (self.fake.base_url, a["id"])
self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc")
def test_download_redirect_other_host(self):
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "a.bin", b"abc")
port = self.fake.server.server_port
self.fake.redirect_content = "http://localhost:%d/secure/moved/%s/a.bin" % (port, a["id"])
with self.assertRaises(agent.RedirectAway) as e:
self.jira.download(self._content_path(a), 100)
self.assertEqual(e.exception.host, "localhost")
self.assertEqual(self.fake.content_gets, 0)
self.assertFalse(any(r[1].startswith("/secure/moved/") for r in self.fake.requests))
def test_download_limit(self):
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 20)
with self.assertRaises(agent.TooBig):
self.jira.download(self._content_path(a), 10)
self.assertEqual(self.jira.download(self._content_path(a), 20), b"x" * 20)
def test_download_429_retry(self):
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "a.bin", b"abc")
self.fake.fail_next.append((429, {"Retry-After": "0"}))
self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc")
self.assertEqual(self.jira.rate_limited, 1)
def test_download_cut_body_is_connection_error(self): # M-1 финального ревью
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 40)
self.fake.lying_length.add(a["id"])
with self.assertRaises(ConnectionError):
self.jira.download(self._content_path(a), 1000)
def test_json_call_redirect_other_host_refused(self): # M-4 финального ревью
self.fake.add("AURORA-1")
port = self.fake.server.server_port
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = "http://localhost:%d/rest/api/2/issue/AURORA-2" % port
with self.assertRaises(agent.HttpError) as e:
self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertIn("localhost", e.exception.text)
self.assertFalse(any(r[1] == "/rest/api/2/issue/AURORA-2" for r in self.fake.requests))
def test_json_call_redirect_same_host_followed(self):
self.fake.add("AURORA-2")
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = self.fake.base_url + "/rest/api/2/issue/AURORA-2"
self.assertEqual(self.jira.get("/rest/api/2/issue/AURORA-1")["key"], "AURORA-2")
def test_retry_on_429_with_retry_after(self): def test_retry_on_429_with_retry_after(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"})) self.fake.fail_next.append((429, {"Retry-After": "0"}))
@ -153,7 +217,7 @@ class ClientsTest(unittest.TestCase):
raise TimeoutError("timed out") raise TimeoutError("timed out")
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken()) err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
with mock.patch("urllib.request.urlopen", side_effect=err): with mock.patch("urllib.request.OpenerDirector.open", side_effect=err):
with self.assertRaises(ConnectionError): with self.assertRaises(ConnectionError):
self.jira.get("/x") self.jira.get("/x")
self.assertEqual(self.jira.calls, 1) self.assertEqual(self.jira.calls, 1)
@ -161,7 +225,7 @@ class ClientsTest(unittest.TestCase):
def test_incomplete_read_is_connection_error(self): def test_incomplete_read_is_connection_error(self):
import http.client import http.client
from unittest import mock from unittest import mock
with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)): with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)):
with self.assertRaises(ConnectionError): with self.assertRaises(ConnectionError):
self.jira.get("/x") self.jira.get("/x")

View File

@ -139,3 +139,115 @@ class EnsureAttachmentTest(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class FetchAttachmentTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.jira = agent.JiraClient(cfg)
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
self.ex.sleep = no_sleep
self.fake.add("AURORA-1650")
def tearDown(self):
self.fake.stop()
self.hub.stop()
def fetch(self, a, key="AURORA-1650"):
return self.ex.run({"id": 7, "type": "fetch_attachment", "params": {"key": key, "attachment_id": a["id"]}})
def test_fetch_screenshot(self): # сценарий «Скачивание скриншота»
data = bytes(range(256)) * 900
a = self.fake.add_attachment("AURORA-1650", "скрин 1.png", data, mime="image/png")
out = self.fetch(a)
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"], {"attachment_id": a["id"], "file": hashlib.sha256(data).hexdigest(),
"size": len(data)})
self.assertEqual(self.hub.uploaded, [data])
self.assertEqual(self.fake.requests[0], ("GET", "/rest/api/2/issue/AURORA-1650"))
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
self.assertEqual(self.fake.requests[1][1],
"/secure/attachment/%s/%%D1%%81%%D0%%BA%%D1%%80%%D0%%B8%%D0%%BD%%201.png" % a["id"])
self.assertEqual(self.fake.writes(), [])
def test_fetch_deleted(self):
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
self.fake.attachments["AURORA-1650"] = []
out = self.fetch(a)
self.assertEqual((out["status"], out["error"]),
("failed", "вложение #%s удалено из AURORA-1650 в Jira" % a["id"]))
self.assertEqual(self.fake.content_gets, 0)
def test_fetch_too_large_known(self):
a = self.fake.add_attachment("AURORA-1650", "запись.mp4", b"x")
a["size"] = 25000000
out = self.fetch(a)
self.assertEqual((out["status"], out["error"]),
("failed", "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"))
self.assertEqual(self.fake.content_gets, 0)
self.assertEqual(self.hub.uploaded, [])
def test_fetch_too_large_unknown(self):
a = self.fake.add_attachment("AURORA-1650", "big.bin", b"x" * (12 * 1024 * 1024))
self.fake.hide_size.add(a["id"])
out = self.fetch(a)
self.assertEqual((out["status"], out["error"]),
("failed", "файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira"))
self.assertEqual(self.hub.uploaded, [])
def test_fetch_unknown_size_ok(self):
a = self.fake.add_attachment("AURORA-1650", "a.json", b'{"a": 1}', mime="application/json")
self.fake.hide_size.add(a["id"])
out = self.fetch(a)
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 8)
def test_fetch_redirect_away(self):
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
self.fake.redirect_content = "http://localhost:%d/secure/moved/%s/a.png" % (self.fake.server.server_port, a["id"])
out = self.fetch(a)
self.assertEqual((out["status"], out["error"]), ("failed", "Jira перенаправила скачивание на localhost"))
self.assertEqual(self.hub.uploaded, [])
def test_fetch_truncated(self):
a = self.fake.add_attachment("AURORA-1650", "a.bin", b"abcdef")
self.fake.short_content.add(a["id"])
out = self.fetch(a)
self.assertEqual((out["status"], out["error"]), ("failed", "файл пришёл не целиком (5 из 6)"))
self.assertEqual(self.hub.uploaded, [])
def test_fetch_empty(self):
a = self.fake.add_attachment("AURORA-1650", "empty.txt", b"")
out = self.fetch(a)
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 0)
self.assertEqual(self.hub.uploaded, [b""])
def test_fetch_hub_down(self):
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
self.hub.reject_files = "drop"
out = self.fetch(a)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertTrue(out["error"].startswith("хаб: "))
def test_fetch_hub_422(self):
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
self.hub.reject_files = (422, {"error": "bad_request", "detail": "content — не base64"})
out = self.fetch(a)
self.assertEqual(out["status"], "failed")
self.assertFalse(out["retryable"])
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
def test_fetch_unknown_size_cut_body_retryable(self): # M-1 финального ревью
a = self.fake.add_attachment("AURORA-1650", "a.json", b"x" * 40)
self.fake.hide_size.add(a["id"])
self.fake.lying_length.add(a["id"])
out = self.fetch(a)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(self.hub.uploaded, [])

View File

@ -1,4 +1,5 @@
import base64 import base64
import hashlib
import io import io
import json import json
import os import os
@ -148,6 +149,19 @@ class FolderTransportTest(unittest.TestCase):
self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha) self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha)
self.assertEqual(self.relay.requests[0]["method"], "GET") self.assertEqual(self.relay.requests[0]["method"], "GET")
def test_upload_file_through_folder(self):
self.run_relay()
data = os.urandom(300 * 1024)
got = self.client().upload_file(data)
self.assertEqual(got, {"file": hashlib.sha256(data).hexdigest(), "size": len(data)})
self.assertEqual(self.hub.uploaded, [data])
self.assertEqual(self.relay.requests[0]["path"], "/agent/files")
def test_upload_file_over_http(self):
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
self.assertEqual(hub.upload_file(b""), {"file": hashlib.sha256(b"").hexdigest(), "size": 0})
self.assertEqual(self.hub.uploaded, [b""])
def test_file_missing_through_folder(self): def test_file_missing_through_folder(self):
self.run_relay() self.run_relay()
with self.assertRaises(agent.HttpError) as e: with self.assertRaises(agent.HttpError) as e:
@ -183,7 +197,7 @@ class FolderIntegrationTest(unittest.TestCase):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 13) self.assertEqual(agent.AGENT_VERSION, 14)
def test_runner_through_folder(self): def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20") self.fake.add("AURORA-1", duedate="2026-10-20")

View File

@ -310,7 +310,7 @@ class RefreshHistoryTest(Base):
self.assertEqual(self.changelog_queries(), []) self.assertEqual(self.changelog_queries(), [])
def test_version(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 13) self.assertEqual(agent.AGENT_VERSION, 14)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -105,6 +105,45 @@ class RefreshTest(unittest.TestCase):
self.assertEqual(len(searches), 1) # комментарии пришли тем же search self.assertEqual(len(searches), 1) # комментарии пришли тем же search
self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests)) self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests))
def test_refresh_attachments_in_state(self): # сценарий «Вложения в состоянии»
self.fake.add("AURORA-1650")
self.fake.add_attachment("AURORA-1650", "скрин 1.png", b"x" * 240000, mime="image/png")
self.fake.add_attachment("AURORA-1650", "ОСВ 60.xlsx", b"y" * 10, name="ivanov", display=None)
out = self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1650"]}})
self.assertEqual(out["status"], "done")
st = self.hub.states[0]["issues"][0]
a = st["attachments"]
self.assertEqual([x["filename"] for x in a], ["скрин 1.png", "ОСВ 60.xlsx"])
self.assertEqual(a[0], {"id": a[0]["id"], "filename": "скрин 1.png", "size": 240000, "mime_type": "image/png",
"author": "Потапова Анна", "created": "2026-10-20T09:15:00.000+0300"})
self.assertIsInstance(a[0]["id"], str)
self.assertEqual(a[1]["author"], "ivanov")
self.assertIn("attachment", self.ex.state_fields().split(","))
self.assertEqual(sum(1 for r in self.fake.requests if r[1].endswith("/search")), 1)
self.assertFalse(any("/secure/attachment/" in r[1] for r in self.fake.requests))
def test_refresh_attachment_without_id_dropped(self):
self.fake.add("AURORA-1")
self.fake.add_attachment("AURORA-1", "a.txt", b"a")
self.fake.add_attachment("AURORA-1", "b.txt", b"b")
self.fake.attachments["AURORA-1"][0]["id"] = None
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
a = self.hub.states[0]["issues"][0]["attachments"]
self.assertEqual([x["filename"] for x in a], ["b.txt"])
def test_refresh_no_attachments_empty_list(self):
self.fake.add("AURORA-1")
self.fake.add("AURORA-2")
self.fake.add_attachment("AURORA-2", "a.txt", b"a")
self.fake.attachments["AURORA-2"][0]["size"] = None
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
self.assertEqual(by_key["AURORA-1"]["attachments"], [])
self.assertIsNone(by_key["AURORA-2"]["attachments"][0]["size"])
self.fake.hide_attachment_field = True
self.ex.run({"id": 6, "type": "refresh", "params": {"keys": ["AURORA-2"]}})
self.assertEqual(self.hub.states[-1]["issues"][0]["attachments"], [])
def test_comments_partial_list_is_read_fully(self): def test_comments_partial_list_is_read_fully(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
for n in range(130): for n in range(130):