diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8b09ddf..84fd40a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,10 +8,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл | Раздел | Классы и функции | Ответственность | Что использует | |---|---|---|---| | Настройки | `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/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `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` | | Цикл | `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` | diff --git a/CLAUDE.md b/CLAUDE.md index f634c32..6385e21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`, `cmd_selftest`, `cmd_run`, `cmd_prune`, `main`). - `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. - `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. - Подробнее: `ARCHITECTURE.md`. @@ -39,7 +39,7 @@ - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. - Тесты разбиты по областям, а не по типам команд: `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_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки). @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, 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`. ## Выкатка новой версии diff --git a/README.md b/README.md index d1332eb..87b5653 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,8 @@ python3 agent.py prune | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `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`), качает содержимое с `/secure/attachment//<имя>` (не больше 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 раз. Счётчики суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `claim`, в каждом отчёте +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в @@ -205,6 +206,9 @@ python3 agent.py prune выкатывается только после v12 (design хаба `projects`, решение 14). Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая загрузка), отправка `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`: «обновите агента»; команды при этом выдаются по-прежнему. diff --git a/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md b/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md new file mode 100644 index 0000000..100379c --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md @@ -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 байт). +- Адрес содержимого — `/secure/attachment//`; `content` из ответа Jira не используется. +- Таймаут скачивания — `max(self.timeout, 120)`. +- Элемент `attachments`: `{id, filename, size, mime_type, author, created}` (`author` — `displayName`, иначе `name`, иначе `None`). +- Тексты ошибок (точно): «вложение # удалено из в Jira»; «файл больше 10 МБ (<размер>) — скачайте в Jira» (неизвестный размер — «больше 10,0 МБ»); «Jira перенаправила скачивание на <хост>»; «файл пришёл не целиком (<получено> из <размер>)». Размер — «N Б» / «N,N КБ» / «N,N МБ» по 1024, запятая. +- Содержимое файла не попадает в лог, журнал и текст ошибок. +- Каждый коммит — с `(#43)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. +- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +- Решения вне спеки и плана — в таблицу `openspec/changes/agent-v14-attachment-fetch/design.md`; находки ревью задачи n — `T.`. + +## 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//<имя>` отдаёт `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)`. diff --git a/openspec/changes/agent-v14-attachment-fetch/.openspec.yaml b/openspec/changes/agent-v14-attachment-fetch/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/agent-v14-attachment-fetch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/agent-v14-attachment-fetch/design.md b/openspec/changes/agent-v14-attachment-fetch/design.md new file mode 100644 index 0000000..ee38f88 --- /dev/null +++ b/openspec/changes/agent-v14-attachment-fetch/design.md @@ -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` (повтор), а не файл | Иначе битый файл становится «скачанным» у хаба | Нет | diff --git a/src/agent.py b/src/agent.py index 1fd9da8..3007ff5 100644 --- a/src/agent.py +++ b/src/agent.py @@ -33,7 +33,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 13 +AGENT_VERSION = 14 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new 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 {} +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: 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 @@ -143,9 +172,12 @@ class Http: if content_type is not None: req.add_header("Content-Type", content_type) 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() status = resp.status + except RedirectAway as e: + self._touch() + raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None except urllib.error.HTTPError as e: self._touch() try: @@ -164,6 +196,47 @@ class Http: except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError 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: """Вызывается на каждый полученный HTTP-ответ (любой код).""" @@ -236,6 +309,10 @@ class JiraClient(Http): "multipart/form-data; boundary=" + boundary, 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): return self.call("GET", path, params=params or None) @@ -370,6 +447,10 @@ class HubClient(Http): """Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file.""" 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 команды @@ -574,6 +655,18 @@ class HubError(Exception): 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: @@ -939,9 +1032,35 @@ class Executor: return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку") 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 --------------------------------------------------------------- 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: 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_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, - "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]: 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, "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]]: """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30); diff --git a/tests/fake_hub.py b/tests/fake_hub.py index ad9fb4d..85adeff 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -32,6 +32,8 @@ class FakeHub: self.histories = [] # тела POST /agent/history self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа self.posts = [] # порядок POST /agent/state и /agent/history + self.uploaded = [] # содержимое, присланное агентом POST /agent/files + self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа self.lock = threading.Lock() hub = self @@ -85,6 +87,10 @@ class FakeHub: if h.headers.get("Authorization") != "Bearer " + self.token: return self._send(h, 401, {"error": "unauthorized"}) 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": h.close_connection = True h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит @@ -141,6 +147,12 @@ class FakeHub: self.posts.append(path) self.states.append(body) 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) if m and method == "GET": self.file_requests += 1 diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 4a4b7a9..bbbe930 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -6,7 +6,7 @@ import socket import threading from datetime import datetime, timedelta, timezone 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)) @@ -85,6 +85,13 @@ class FakeJira: self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа self.attachment_limit = None # байт: больше — 413, как предел вложений Jira 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//<имя> — запросы содержимого + 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.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] @@ -162,15 +169,23 @@ class FakeJira: self.comments.setdefault(key, []).append(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 - 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) return a def attachment_json(self, a): - return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"], - "filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"]} + 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"], "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): 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] out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), "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, [])] return {"key": key, "id": f["id"], "fields": out} @@ -250,6 +265,14 @@ class FakeJira: code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} except Exception as 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": self.drop_next_post_issue = False h.close_connection = True @@ -262,6 +285,38 @@ class FakeJira: return self._send(h, code, payload) + def _content(self, h, path): + """Содержимое вложения, как /secure/attachment//<имя> 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): data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" h.send_response(code) diff --git a/tests/test_clients.py b/tests/test_clients.py index 568fd6f..55ad69e 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -90,6 +90,70 @@ class ClientsTest(unittest.TestCase): self.jira.upload("AURORA-1", "a.txt", b"abc") 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): self.fake.add("AURORA-1") self.fake.fail_next.append((429, {"Retry-After": "0"})) @@ -153,7 +217,7 @@ class ClientsTest(unittest.TestCase): raise TimeoutError("timed out") 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): self.jira.get("/x") self.assertEqual(self.jira.calls, 1) @@ -161,7 +225,7 @@ class ClientsTest(unittest.TestCase): def test_incomplete_read_is_connection_error(self): import http.client 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): self.jira.get("/x") diff --git a/tests/test_executor_attachments.py b/tests/test_executor_attachments.py index 1aa352c..4269d5d 100644 --- a/tests/test_executor_attachments.py +++ b/tests/test_executor_attachments.py @@ -139,3 +139,115 @@ class EnsureAttachmentTest(unittest.TestCase): if __name__ == "__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, []) diff --git a/tests/test_folder.py b/tests/test_folder.py index af7840f..41a72f9 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,4 +1,5 @@ import base64 +import hashlib import io import json 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]["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): self.run_relay() 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) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 13) + self.assertEqual(agent.AGENT_VERSION, 14) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") diff --git a/tests/test_history.py b/tests/test_history.py index 42955e1..ba9f754 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -310,7 +310,7 @@ class RefreshHistoryTest(Base): self.assertEqual(self.changelog_queries(), []) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 13) + self.assertEqual(agent.AGENT_VERSION, 14) if __name__ == "__main__": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index bda5174..3c336f6 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -105,6 +105,45 @@ class RefreshTest(unittest.TestCase): self.assertEqual(len(searches), 1) # комментарии пришли тем же search 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): self.fake.add("AURORA-1") for n in range(130):