Агент 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`, проверяет обязательные поля | — |
| 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` |
| Журнал | `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` |

View File

@ -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`.
## Выкатка новой версии

View File

@ -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`), качает содержимое с `<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 раз. Счётчики
суммируются. После исчерпания — `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`: «обновите агента»; команды
при этом выдаются по-прежнему.

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

View File

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

View File

@ -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/<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.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/<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):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""
h.send_response(code)

View File

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

View File

@ -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, [])

View File

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

View File

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

View File

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