Агент v14: список вложений в refresh, команда fetch_attachment (#43) #44
@ -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` |
|
||||
|
||||
@ -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`.
|
||||
|
||||
## Выкатка новой версии
|
||||
|
||||
@ -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`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
|
||||
92
docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md
Normal file
92
docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md
Normal 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)`.
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
21
openspec/changes/agent-v14-attachment-fetch/design.md
Normal file
21
openspec/changes/agent-v14-attachment-fetch/design.md
Normal 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` (повтор), а не файл | Иначе битый файл становится «скачанным» у хаба | Нет |
|
||||
148
src/agent.py
148
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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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, [])
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user