Merge pull request 'Агент v11: тип «Ошибка», ensure_attachment, устойчивое дочитывание комментариев (#31)' (#32) from feat/31-bug-and-attachments into main

This commit is contained in:
aep 2026-10-16 09:10:17 +03:00
commit 6740ccf5b8
16 changed files with 2062 additions and 37 deletions

View File

@ -8,10 +8,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| Раздел | Классы и функции | Ответственность | Что использует | | Раздел | Классы и функции | Ответственность | Что использует |
|---|---|---|---| |---|---|---|---|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `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 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |

View File

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

View File

@ -165,12 +165,13 @@ python3 agent.py prune
| Тип | Что делает | | Тип | Что делает |
|---|---| |---|---|
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт; возвращает `key` | | `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` |
| `ensure_fields` | читает задачу и пишет только отличающиеся поля | | `ensure_fields` | читает задачу и пишет только отличающиеся поля |
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог | | `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет | | `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет |
Правила работы: Правила работы:
@ -189,11 +190,14 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 9) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 11) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
`refresh` (номера 6–8 заняты живой проверкой самообновления). `refresh` (номера 6–8 заняты живой проверкой самообновления).
Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации
(тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30).
Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.
@ -238,6 +242,8 @@ python3 agent.py prune
С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время
и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через
`/issue/{key}/comment`. `/issue/{key}/comment`.
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на 5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
завершающий перевод строки) — изменение. завершающий перевод строки) — изменение.

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,29 @@
# agent-v11-bug-attachments — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/bug-and-attachments/` (issue aep/jira-hub#43, aep/jira-agent#31).
Агентская часть — раздел «Агент v11» дизайна хаба и требования `agent` «Тип задачи и вложения» (ADDED) и
«Чтение состояния» (MODIFIED: M-1, M-2 из aep/jira-agent#30).
Решения владельца 15.10.2026 (brainstorming jira-hub#43): файл приходит только из MCP; обязательные поля «Ошибки» — как у
«Задачи»; файл хранится в хабе, агент забирает его отдельным запросом `GET /agent/files/{sha256}` (base64 в JSON).
## Decisions made without discussion
Решения 1–8 записаны в `jira-hub/openspec/changes/bug-and-attachments/design.md`
(раздел «Decisions made without discussion»); здесь — только решения агентской части.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-20 | 2026-10-15 | Любой 404 от `GET /agent/files/{sha}` — `failed` «файл удалён с хаба, загрузите заново», без разбора кода `no_file` в теле | Спека hub-api: у маршрута 404 бывает только `no_file`; агент v11 публикуется после выкатки хаба с маршрутом (design «Выкатка», шаги 1 и 3) | Если хаб без маршрута (откат хаба) — вводящий в заблуждение текст «загрузите заново» вместо «нет маршрута»; повторная загрузка не поможет, причина видна по логу хаба |
| R-21 | 2026-10-15 | Таймаут `GET /agent/files` — обычный `timeout` агента (60 с), не поднимается до 120 с, как у загрузки в Jira | design Risks: круг папки для файла 10 МБ (≈13,4 МБ JSON) — секунды, «таймаут вызова агента 60 с с запасом»; таймаут — `ConnectionError` → повтор как обрыв связи с хабом | На медленной папке большой файл не успеет за 60 с: 4 попытки и `failed` «результат неизвестен»; лечится `timeout` в `agent.json` |
| R-22 | 2026-10-15 | Ответ хаба на файл с кодом 2xx, но не JSON — временный сбой («ответ на файл <sha> не JSON») без текста `HttpError` | Текст `Http` содержит начало тела, то есть base64 содержимого, а спека запрещает содержимое в журнале и логе; обрезанный ответ — сбой пути, как повреждение | Диагностика беднее: начала тела в ошибке нет, причину искать по логу хаба/реле |
| R-23 | 2026-10-15 | В `filename="…"` кавычка и переводы строк кодируются как у браузеров (`%22`, `%0D`, `%0A`); при поиске уже приложенного вложения сравниваются обе формы имени — исходная и «браузерная» | Запасная защита: с решения 20 хаба (16.10.2026) хаб отвергает `"` и управляющие символы при постановке, и для его команд путь не срабатывает; остаётся на случай старого хаба или ручной постановки. Сырая кавычка ломает заголовок части; если Jira сохранит имя в закодированной форме, без второй формы каждый повтор команды создавал бы дубль | Вложение с такими символами в Jira может называться `…%22…`; другое вложение, чьё имя совпало с «браузерной» формой, будет принято за уже приложенное |
| R-24 | 2026-10-15 | Ответ Jira на загрузку не список или без `id` у первого элемента — `failed` «Jira не вернула id вложения в ответе на загрузку», без повтора | Спека: результат — `attachment_id` из ответа; повтор команды человеком найдёт вложение по имени и размеру и вернёт `done` с id | Если Jira на самом деле не сохранила вложение, `failed` без повтора требует ручного повтора команды |
| R-25 | 2026-10-15 | Загрузка вложения идёт через тот же повтор 429/503 по `Retry-After`, что и остальные вызовы Jira (`JiraClient._retry`) | 429/503 — запрос не обработан, повтор безопасен; спека «Ограничение темпа и 429» не делает исключений для записи | 503 от прокси после фактической записи даст второе вложение; вероятность низкая, лишнее удаляется руками |
| R-26 | 2026-10-15 | Тип задачи проверяется до поиска дубля в `ensure_issue`: без настройки типа — ни одного вызова Jira, даже если задача с той же темой уже есть | Спека: «`failed` без вызова Jira»; настройку могли убрать между постановкой и выдачей (design решение 6) | Повтор `ensure_issue` по уже созданной задаче после удаления настройки типа даёт `failed` вместо `done` с найденным ключом |
| R-27 | 2026-10-16 | Трейлер коммитов исполнения — `Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>`, а не `Opus 5.5` из текста плана | Указание окружения об атрибуции приоритетнее плана; план исполняет Sonnet | Неверное имя модели в трейлере 4–5 коммитов; правится при rebase |
## Наблюдения вне ревью
- O-1: README к началу исполнения утверждал «сейчас 9», хотя `AGENT_VERSION` был 10 (v10 не обновила пункт); исправлено вместе с обновлением до 11.
- O-2: полный набор занимает ≈2 минуты (230 → 262 теста), дольше таймаута foreground-команды; прогоны `task-done` запускались в фоне.

View File

@ -31,9 +31,9 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
AGENT_VERSION = 10 AGENT_VERSION = 11
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@ -98,6 +98,20 @@ def write_update_state(path: str, state: str, from_version, to_version, sha256,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# HTTP # HTTP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def wire_filename(name: str) -> str:
"""Имя для filename="…" в multipart: кавычка и переводы строк — %22, %0D, %0A, как у браузеров (HTML5);
остальное — UTF-8 как есть (Jira Server читает filename в кодировке запроса)."""
return name.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A")
def multipart_file(boundary: str, filename: str, data: bytes) -> bytes:
"""Тело multipart/form-data с одной частью file (спека agent «Тип задачи и вложения»)."""
disposition = "form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s" % (
wire_filename(filename), urllib.parse.quote(filename, safe=""))
head = "--%s\r\nContent-Disposition: %s\r\nContent-Type: application/octet-stream\r\n\r\n" % (boundary, disposition)
return head.encode("utf-8") + data + ("\r\n--%s--\r\n" % boundary).encode("ascii")
class HttpError(Exception): class HttpError(Exception):
def __init__(self, code: int, text: str, headers=None): def __init__(self, code: int, text: str, headers=None):
super().__init__("HTTP %d: %s" % (code, text[:300])) super().__init__("HTTP %d: %s" % (code, text[:300]))
@ -112,16 +126,22 @@ class Http:
return "<%s %s>" % (self.__class__.__name__, self.base) return "<%s %s>" % (self.__class__.__name__, self.base)
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None,
params, timeout)
def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None,
timeout=None) -> Any:
"""Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError."""
url = self.base + path url = self.base + path
if params: if params:
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method) req = urllib.request.Request(url, data=data, method=method)
for k, v in self.headers.items(): for k, v in self.headers.items():
req.add_header(k, v) req.add_header(k, v)
req.add_header("Accept", "application/json") req.add_header("Accept", "application/json")
if data is not None: if content_type is not None:
req.add_header("Content-Type", "application/json; charset=utf-8") req.add_header("Content-Type", content_type)
try: try:
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp:
raw = resp.read() raw = resp.read()
@ -184,10 +204,11 @@ class JiraClient(Http):
def reset_counters(self) -> None: def reset_counters(self) -> None:
self.calls, self.rate_limited = 0, 0 self.calls, self.rate_limited = 0, 0
def call(self, method, path, body=None, params=None): def _retry(self, request: Callable[[], Any]) -> Any:
"""429/503 — повтор по Retry-After (до 5 запросов, не более 4 ожиданий); прочие ошибки — наружу."""
for attempt in range(1, 6): for attempt in range(1, 6):
try: try:
return super().call(method, path, body, params) return request()
except HttpError as e: except HttpError as e:
if e.code in (429, 503) and attempt < 5: if e.code in (429, 503) and attempt < 5:
wait = 60 * attempt wait = 60 * attempt
@ -203,6 +224,18 @@ class JiraClient(Http):
self.rate_limited += 1 self.rate_limited += 1
raise raise
def call(self, method, path, body=None, params=None):
return self._retry(lambda: Http.call(self, method, path, body, params))
def upload(self, key: str, filename: str, data: bytes) -> Any:
"""Вложение в задачу: multipart, X-Atlassian-Token: no-check (заголовок клиента), таймаут не меньше 120 с.
Содержимое не логируется."""
boundary = uuid.uuid4().hex
body = multipart_file(boundary, filename, data)
return self._retry(lambda: self.send("POST", API + "/issue/%s/attachments" % key, body,
"multipart/form-data; boundary=" + boundary,
timeout=max(self.timeout, 120)))
def get(self, path, **params): def get(self, path, **params):
return self.call("GET", path, params=params or None) return self.call("GET", path, params=params or None)
@ -330,6 +363,10 @@ class HubClient(Http):
def known_keys(self) -> List[str]: def known_keys(self) -> List[str]:
return self.call("GET", "/agent/known-keys")["keys"] return self.call("GET", "/agent/known-keys")["keys"]
def file(self, sha256: str) -> dict:
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
return self.call("GET", "/agent/files/%s" % sha256)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Журнал: только дозапись; started → finished → acked по id команды # Журнал: только дозапись; started → finished → acked по id команды
@ -512,6 +549,9 @@ class HubError(Exception):
self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500) self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500)
FILE_GONE = "файл удалён с хаба, загрузите заново"
class Executor: class Executor:
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
self.jira, self.s, self.hub = jira, settings, hub self.jira, self.s, self.hub = jira, settings, hub
@ -572,8 +612,14 @@ class Executor:
return {self.s.get("priority_value_key") or "name": mapped} return {self.s.get("priority_value_key") or "name": mapped}
def issue_type_ref(self, kind: str) -> dict: def issue_type_ref(self, kind: str) -> dict:
it = (self.s.get("issue_types") or {}).get(kind) or {} """Тип задачи Jira из issue_types.<kind>: {id}, если id задан, иначе {name}; нет ни того, ни другого —
return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")} ошибка конфигурации, а не {"name": None}, на которое Jira ответит 400 без понятной причины (спека agent v11)."""
it = (self.s.get("issue_types") or {}).get(kind)
if isinstance(it, dict) and it.get("id"):
return {"id": str(it["id"])}
if isinstance(it, dict) and it.get("name"):
return {"name": it["name"]}
raise ConfigGap("нет настройки issue_types.%s" % kind)
def epic_field_id(self) -> str: def epic_field_id(self) -> str:
ep = self.s.get("epic_link") or {} ep = self.s.get("epic_link") or {}
@ -593,6 +639,7 @@ class Executor:
# --- ensure_issue ---------------------------------------------------------- # --- ensure_issue ----------------------------------------------------------
def do_ensure_issue(self, p: dict, cmd: dict) -> dict: def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
issuetype = self.issue_type_ref(p["issue_type"]) # до поиска дубля: без настройки — ни одного вызова Jira
summary = (p["summary"] or "").strip() summary = (p["summary"] or "").strip()
jql = "project = %s AND reporter = currentUser() AND created >= -7d ORDER BY created DESC" % self.s["project_key"] jql = "project = %s AND reporter = currentUser() AND created >= -7d ORDER BY created DESC" % self.s["project_key"]
start = 0 start = 0
@ -605,8 +652,7 @@ class Executor:
start += len(issues) start += len(issues)
if not issues or start >= int(found.get("total") or 0): if not issues or start >= int(found.get("total") or 0):
break break
fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]), fields = {"project": {"key": self.s["project_key"]}, "issuetype": issuetype, "summary": p["summary"]}
"summary": p["summary"]}
if p.get("description") is not None: if p.get("description") is not None:
fields[self.fid("description")] = p["description"] fields[self.fid("description")] = p["description"]
if p.get("priority"): if p.get("priority"):
@ -825,6 +871,50 @@ class Executor:
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
return self._out("done", {"sprint": want, "from": cur}) return self._out("done", {"sprint": want, "from": cur})
# --- ensure_attachment -----------------------------------------------------
def hub_file(self, sha: str, size: int) -> Optional[bytes]:
"""Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором,
как обрыв связи с хабом. Содержимое файла в текст ошибки не попадает."""
try:
got = self.hub.file(sha)
except HttpError as e:
if e.code == 404:
return None
if 200 <= e.code < 300: # ответ не JSON: в тексте Http — начало тела, то есть содержимого
raise HubError(ConnectionError("ответ на файл %s не JSON" % sha)) from None
raise HubError(e) from None
except ConnectionError as e:
raise HubError(e) from None
content = got.get("content") if isinstance(got, dict) else None
data = None
if isinstance(content, str):
try:
data = base64.b64decode(content, validate=True)
except ValueError: # binascii.Error — подкласс ValueError
data = None
if data is None or len(data) != size or hashlib.sha256(data).hexdigest() != sha:
raise HubError(ConnectionError("файл %s пришёл повреждённым: sha256 или размер не сходятся" % sha))
return data
def do_ensure_attachment(self, p: dict, cmd: dict) -> dict:
key, sha, filename, size = p["key"], p["file"], p["filename"], p.get("size")
if size is None: # хаб не нашёл файл при выдаче
return self._out("failed", error=FILE_GONE)
names = {filename, wire_filename(filename)} # Jira могла сохранить имя в «браузерной» форме (R-23)
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
for a in (issue.get("fields") or {}).get("attachment") or []:
if a.get("filename") in names and a.get("size") == size:
return self._out("done", {"attachment_id": str(a.get("id")), "existed": True})
data = self.hub_file(sha, size)
if data is None:
return self._out("failed", error=FILE_GONE)
log(" вложение «%s» (%d байт) → %s" % (filename, size, key))
created = self.jira.upload(key, filename, data)
first = created[0] if isinstance(created, list) and created and isinstance(created[0], dict) else {}
if not first.get("id"):
return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку")
return self._out("done", {"attachment_id": str(first["id"]), "created": True})
# --- refresh --------------------------------------------------------------- # --- refresh ---------------------------------------------------------------
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
"issuetype", "created", "updated", "issuelinks", "comment"] "issuetype", "created", "updated", "issuelinks", "comment"]
@ -900,22 +990,36 @@ class Executor:
return {"id": str(c.get("id")), "author": a.get("displayName") or a.get("name") or None, return {"id": str(c.get("id")), "author": a.get("displayName") or a.get("name") or None,
"created": c.get("created"), "updated": c.get("updated"), "body": c.get("body") or ""} "created": c.get("created"), "updated": c.get("updated"), "body": c.get("body") or ""}
def comments_of(self, raw: dict) -> List[dict]: def comments_of(self, raw: dict) -> Optional[List[dict]]:
"""Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично
через /issue/{key}/comment и взять полный список вместо частичного.""" через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30);
403/404 при дочитывании — None: задача уходит без comments, хаб оставит прежние (M-2 #30)."""
key = raw["key"]
block = (raw.get("fields") or {}).get("comment") or {} block = (raw.get("fields") or {}).get("comment") or {}
got = list(block.get("comments") or []) got = list(block.get("comments") or [])
total = block.get("total") total = block.get("total")
if total is not None and len(got) < int(total): if total is not None and len(got) < int(total):
got, start = [], 0 got, start = [], 0
while True: try:
page = self.jira.get(API + "/issue/%s/comment" % raw["key"], startAt=start, maxResults=100) while True:
part = page.get("comments") or [] page = self.jira.get(API + "/issue/%s/comment" % key, startAt=start, maxResults=100)
got += part part = page.get("comments") or []
start += len(part) got += part
if not part or page.get("total") is None or start >= int(page["total"]): start += len(part)
break if not part or page.get("total") is None or start >= int(page["total"]):
return [self.comment_state(c) for c in got] break
except HttpError as e:
if e.code not in (403, 404):
raise
log("refresh: %s — комментарии не дочитаны (HTTP %d), задача уходит без comments" % (key, e.code))
return None
out = []
for c in got:
if c.get("id") is None or str(c.get("id")) == "":
log("refresh: %s — комментарий без id пропущен" % key)
continue
out.append(self.comment_state(c))
return out
def sprint_names(self, keys: List[str]) -> Dict[str, str]: def sprint_names(self, keys: List[str]) -> Dict[str, str]:
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, # Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет,
@ -969,7 +1073,9 @@ class Executor:
for r in everything: for r in everything:
st = self.to_state(r) st = self.to_state(r)
st["sprint"] = names.get(r["key"]) st["sprint"] = names.get(r["key"])
st["comments"] = self.comments_of(r) comments = self.comments_of(r)
if comments is not None: # None — нет доступа к комментариям: поле не передаём (M-2 #30)
st["comments"] = comments
issues.append(st) issues.append(st)
for l in self.links_from(r): for l in self.links_from(r):
sig = (l["source_key"], l["target_key"], l["link_type"]) sig = (l["source_key"], l["target_key"], l["link_type"])

View File

@ -9,12 +9,13 @@ if SRC not in sys.path:
import agent # noqa: E402 import agent # noqa: E402
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
"ensure_sprint": 0, "refresh": 0} "ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0}
SETTINGS = { SETTINGS = {
"base_jql": "project = AURORA AND labels = AWPCA", "base_jql": "project = AURORA AND labels = AWPCA",
"project_key": "AURORA", "project_key": "AURORA",
"issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}, "issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"},
"bug": {"name": "Ошибка", "id": None}},
"epic_name_field_id": "customfield_15304", "epic_name_field_id": "customfield_15304",
"field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels", "field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels",
"planned_start": "customfield_10801", "planned_end": "customfield_10802"}, "planned_start": "customfield_10801", "planned_end": "customfield_10802"},

View File

@ -1,4 +1,6 @@
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти.""" """Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import base64
import hashlib
import json import json
import re import re
import threading import threading
@ -23,6 +25,8 @@ class FakeHub:
self.in_flight = None self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0 self.release_requests = 0
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
self.file_requests = 0
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.lock = threading.Lock() self.lock = threading.Lock()
hub = self hub = self
@ -54,6 +58,19 @@ class FakeHub:
self.queue.append(cid) self.queue.append(cid)
return cid return cid
def put_file(self, data):
sha = hashlib.sha256(data).hexdigest()
self.files[sha] = data
return sha
def _issued(self, c):
"""Параметры при выдаче: как хаб, к ensure_attachment дописывается size файла (None — файла нет)."""
params = dict(c["params"])
if c["type"] == "ensure_attachment":
data = self.files.get(params.get("file"))
params["size"] = len(data) if data is not None else None
return params
def _release_ref(self): def _release_ref(self):
r = self.release r = self.release
return {"version": r["version"], "sha256": r["sha256"]} if r else None return {"version": r["version"], "sha256": r["sha256"]} if r else None
@ -97,7 +114,7 @@ class FakeHub:
self.commands[cid]["status"] = "claimed" self.commands[cid]["status"] = "claimed"
self.in_flight = cid self.in_flight = cid
c = self.commands[cid] c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()} "settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
if path == "/agent/release": if path == "/agent/release":
self.release_requests += 1 self.release_requests += 1
@ -109,10 +126,17 @@ class FakeHub:
if path == "/agent/state": if path == "/agent/state":
self.states.append(body) self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
m = re.match(r"^/agent/files/([^/]+)$", path)
if m and method == "GET":
self.file_requests += 1
data = self.files.get(m.group(1))
if data is None:
return 404, {"error": "no_file", "detail": "файла %s нет в хранилище" % m.group(1)}
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
m = re.match(r"^/agent/commands/(\d+)$", path) m = re.match(r"^/agent/commands/(\d+)$", path)
if m: if m:
c = self.commands.get(int(m.group(1))) c = self.commands.get(int(m.group(1)))
return (200, c) if c else (404, {"error": "not_found"}) return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
m = re.match(r"^/agent/commands/(\d+)/result$", path) m = re.match(r"^/agent/commands/(\d+)/result$", path)
if m: if m:
cid = int(m.group(1)) cid = int(m.group(1))

View File

@ -6,7 +6,7 @@ import socket
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, unquote, urlparse
MSK = timezone(timedelta(hours=3)) MSK = timezone(timedelta(hours=3))
@ -24,6 +24,43 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")
def parse_multipart(raw, content_type):
"""Строгий разбор multipart/form-data, как commons-fileupload Jira Server с кодировкой заголовков UTF-8:
[{"headers": {имя в нижнем регистре: значение}, "data": bytes}]. Нарушение формата — AssertionError (фейк ответит 400)."""
m = re.search(r"boundary=([^;\s]+)", content_type)
assert m, "нет boundary в Content-Type"
delim = b"--" + m.group(1).encode("ascii")
head, tail = delim + b"\r\n", b"\r\n" + delim + b"--\r\n"
assert raw.startswith(head), "тело не начинается с границы"
assert raw.endswith(tail), "нет закрывающей границы"
parts = []
for chunk in raw[len(head):len(raw) - len(tail)].split(b"\r\n" + delim + b"\r\n"):
hdr, sep, data = chunk.partition(b"\r\n\r\n")
assert sep, "у части нет пустой строки после заголовков"
headers = {}
for line in hdr.decode("utf-8").split("\r\n"):
name, _, value = line.partition(":")
headers[name.strip().lower()] = value.strip()
parts.append({"headers": headers, "data": data})
return parts
def disposition_params(value):
"""Параметры Content-Disposition части: name; filename — как в кавычках, без percent-декодирования (так читает
Jira Server); filename* — после UTF-8'' с percent-декодированием."""
out = {}
m = re.search(r'(?:^|;)\s*name="([^"]*)"', value)
if m:
out["name"] = m.group(1)
m = re.search(r'(?:^|;)\s*filename="([^"]*)"', value)
if m:
out["filename"] = m.group(1)
m = re.search(r"(?:^|;)\s*filename\*=UTF-8''([^;\s]+)", value)
if m:
out["filename*"] = unquote(m.group(1), encoding="utf-8", errors="strict")
return out
class FakeJira: class FakeJira:
def __init__(self, project="AURORA"): def __init__(self, project="AURORA"):
self.project = project self.project = project
@ -38,6 +75,14 @@ class FakeJira:
self.requests = [] # [(метод, путь)] self.requests = [] # [(метод, путь)]
self.queries = [] # параметры запросов (q) — для проверки JQL self.queries = [] # параметры запросов (q) — для проверки JQL
self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа
self.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи)
self.attachments = {} # ключ → [{id, filename, size, mimeType, data}]
self.attachment_seq = 20000
self.uploads = [] # разобранные POST /attachments: {key, token, content_type, headers, disposition}
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
self.bad_attachment_response = False # ответить на загрузку пустым списком
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
self.lock = threading.Lock() self.lock = threading.Lock()
fake = self fake = self
@ -93,6 +138,16 @@ class FakeJira:
self.comments.setdefault(key, []).append(c) self.comments.setdefault(key, []).append(c)
return c return c
def add_attachment(self, key, filename, data, mime="application/octet-stream"):
self.attachment_seq += 1
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data}
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"]}
def writes(self): def writes(self):
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]
@ -114,6 +169,8 @@ class FakeJira:
shown = lst if cap is None else lst[:cap] shown = lst if cap is None else lst[:cap]
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
"comments": shown} "comments": shown}
if fields is None or "attachment" in fields:
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
return {"key": key, "id": f["id"], "fields": out} return {"key": key, "id": f["id"], "fields": out}
def links_of(self, key): def links_of(self, key):
@ -148,8 +205,14 @@ class FakeJira:
url = urlparse(h.path) url = urlparse(h.path)
q = {k: v[0] for k, v in parse_qs(url.query).items()} q = {k: v[0] for k, v in parse_qs(url.query).items()}
length = int(h.headers.get("Content-Length") or 0) length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} raw = h.rfile.read(length) if length else b""
ctype = h.headers.get("Content-Type") or ""
if ctype.startswith("multipart/form-data"):
body = {"_raw": raw, "_content_type": ctype} # разбирает маршрут: ошибка формата — 400
else:
body = json.loads(raw.decode("utf-8")) if raw else {}
with self.lock: with self.lock:
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
self.requests.append((method, url.path)) self.requests.append((method, url.path))
self.queries.append(q) self.queries.append(q)
if self.fail_next: if self.fail_next:
@ -166,6 +229,11 @@ class FakeJira:
h.close_connection = True h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит
return return
if self.drop_next_post_attachment and method == "POST" and url.path.endswith("/attachments"):
self.drop_next_post_attachment = False
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # вложение сохранено, ответа клиент не получит
return
self._send(h, code, payload) self._send(h, code, payload)
def _send(self, h, code, payload, headers=None): def _send(self, h, code, payload, headers=None):
@ -234,6 +302,8 @@ class FakeJira:
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p) m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p)
if m: if m:
key = m.group(1) key = m.group(1)
if method == "GET" and key in self.comment_denied:
return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]}
self.issues[key] self.issues[key]
lst = self.comments.setdefault(key, []) lst = self.comments.setdefault(key, [])
if method == "GET": if method == "GET":
@ -250,6 +320,26 @@ class FakeJira:
if c["id"] == m.group(2): if c["id"] == m.group(2):
c["body"] = body["body"] c["body"] = body["body"]
return 200, {} return 200, {}
m = re.match(r"^/rest/api/2/issue/([^/]+)/attachments$", p)
if m and method == "POST":
key = m.group(1)
self.issues[key]
if self.last_headers.get("x-atlassian-token") != "no-check":
return 403, {"errorMessages": ["XSRF check failed"]} # как Jira Server без заголовка
parts = parse_multipart(body["_raw"], body["_content_type"])
assert len(parts) == 1, "ожидалась одна часть, пришло %d" % len(parts)
part = parts[0]
disp = disposition_params(part["headers"].get("content-disposition", ""))
assert disp.get("name") == "file", "часть называется не file: %r" % disp
self.uploads.append({"key": key, "token": self.last_headers.get("x-atlassian-token"),
"content_type": body["_content_type"], "headers": part["headers"], "disposition": disp})
if self.attachment_limit is not None and len(part["data"]) > self.attachment_limit:
return 413, {"errorMessages": ["Размер вложения превышает предел %d байт" % self.attachment_limit]}
a = self.add_attachment(key, disp["filename"], part["data"],
part["headers"].get("content-type") or "application/octet-stream")
if self.bad_attachment_response:
return 200, []
return 200, [self.attachment_json(a)]
m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p) m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p)
if m: if m:
key = m.group(1) key = m.group(1)

View File

@ -1,4 +1,5 @@
import unittest import unittest
import urllib.parse
from _common import agent, jira_cfg, no_sleep from _common import agent, jira_cfg, no_sleep
from fake_jira import FakeJira from fake_jira import FakeJira
@ -21,6 +22,74 @@ class ClientsTest(unittest.TestCase):
self.assertIsNotNone(self.jira.last_call_at) self.assertIsNotNone(self.jira.last_call_at)
self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1")) self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))
def test_multipart_file_bytes(self):
body = agent.multipart_file("b0", "Отчёт.txt", b"x\r\ny")
q = urllib.parse.quote("Отчёт.txt", safe="")
expected = ('--b0\r\nContent-Disposition: form-data; name="file"; filename="Отчёт.txt"; '
"filename*=UTF-8''%s\r\nContent-Type: application/octet-stream\r\n\r\n" % q).encode("utf-8") \
+ b"x\r\ny" + b"\r\n--b0--\r\n"
self.assertEqual(body, expected)
def test_wire_filename(self):
self.assertEqual(agent.wire_filename('а "б"\r\n.txt'), "а %22б%22%0D%0A.txt")
self.assertEqual(agent.wire_filename("ОСВ 60.xlsx"), "ОСВ 60.xlsx")
def test_upload_cyrillic_name(self):
self.fake.add("AURORA-1")
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
data = bytes(range(256)) * 40
resp = self.jira.upload("AURORA-1", name, data)
self.assertIsInstance(resp, list)
a = self.fake.attachments["AURORA-1"][0]
self.assertEqual((resp[0]["id"], a["filename"], a["size"], a["data"]), (a["id"], name, len(data), data))
up = self.fake.uploads[-1]
self.assertEqual(up["token"], "no-check")
self.assertTrue(up["content_type"].startswith("multipart/form-data; boundary="))
self.assertEqual(len(up["content_type"].split("boundary=")[1]), 32) # uuid4().hex
self.assertEqual(up["headers"]["content-type"], "application/octet-stream")
self.assertEqual(up["disposition"]["filename*"], name)
self.assertEqual(self.jira.calls, 1)
self.assertEqual(self.fake.requests[-1], ("POST", "/rest/api/2/issue/AURORA-1/attachments"))
def test_upload_filename_with_quote_and_newline(self):
self.fake.add("AURORA-1")
name = 'итог "кв.2"\r\n.txt'
self.jira.upload("AURORA-1", name, b"abc")
up = self.fake.uploads[-1]
self.assertEqual(up["disposition"]["filename"], "итог %22кв.2%22%0D%0A.txt")
self.assertEqual(up["disposition"]["filename*"], name)
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
self.assertEqual(self.fake.attachments["AURORA-1"][0]["data"], b"abc")
def test_upload_needs_xsrf_header(self):
self.fake.add("AURORA-1")
self.jira.headers = {k: v for k, v in self.jira.headers.items() if k != "X-Atlassian-Token"}
with self.assertRaises(agent.HttpError) as cm:
self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(cm.exception.code, 403)
self.assertNotIn("AURORA-1", self.fake.attachments)
def test_upload_retries_429(self):
self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"}))
self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
self.assertEqual(self.jira.rate_limited, 1)
self.assertEqual(self.jira.calls, 2)
def test_upload_timeout_at_least_120(self):
self.fake.add("AURORA-1")
seen = []
orig = self.jira.send
def spy(*a, **kw):
seen.append(kw.get("timeout"))
return orig(*a, **kw)
self.jira.send = spy
self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с
def test_retry_on_429_with_retry_after(self): def test_retry_on_429_with_retry_after(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"})) self.fake.fail_next.append((429, {"Retry-After": "0"}))

View File

@ -0,0 +1,141 @@
import hashlib
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira
SHA_ABC = hashlib.sha256(b"abc").hexdigest()
class EnsureAttachmentTest(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-1")
def tearDown(self):
self.fake.stop()
self.hub.stop()
def run_cmd(self, **params):
return self.ex.run({"id": 1, "type": "ensure_attachment", "params": params})
def test_upload_cyrillic_name(self):
data = bytes(range(256)) * 8
sha = self.hub.put_file(data)
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
out = self.run_cmd(key="AURORA-1", file=sha, filename=name, size=len(data))
self.assertEqual(out["status"], "done", out)
a = self.fake.attachments["AURORA-1"][0]
self.assertEqual(out["result"], {"attachment_id": a["id"], "created": True})
self.assertEqual((a["filename"], a["size"], a["data"]), (name, len(data), data))
self.assertEqual(self.fake.requests, [("GET", "/rest/api/2/issue/AURORA-1"),
("POST", "/rest/api/2/issue/AURORA-1/attachments")])
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
self.assertEqual(self.hub.file_requests, 1)
self.assertEqual(out["jira_calls"], 2)
def test_existing_same_name_and_size(self):
a = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", b"xyz") # другое содержимое, тот же размер
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="ОСВ 60.xlsx", size=3)
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
self.assertEqual(out["jira_calls"], 1)
self.assertEqual(self.hub.file_requests, 0) # файл у хаба не запрашивается
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
def test_same_name_other_size_uploads_second(self):
old = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", "старое содержимое".encode("utf-8"))
sha = self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=sha, filename="ОСВ 60.xlsx", size=3)
self.assertEqual(out["status"], "done")
self.assertTrue(out["result"]["created"])
self.assertNotEqual(out["result"]["attachment_id"], old["id"])
self.assertEqual([x["filename"] for x in self.fake.attachments["AURORA-1"]], ["ОСВ 60.xlsx", "ОСВ 60.xlsx"])
def test_existing_under_wire_name(self):
name = 'итог "кв.2".txt'
a = self.fake.add_attachment("AURORA-1", agent.wire_filename(name), b"abc") # так Jira сохранила прошлую загрузку
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename=name, size=3)
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
self.assertEqual(self.hub.file_requests, 0)
def test_size_null_fails_without_calls(self):
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=None)
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
self.assertNotIn("retryable", out)
self.assertEqual(self.fake.requests, [])
self.assertEqual(self.hub.file_requests, 0)
def test_hub_404_file_gone(self):
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) # в хранилище хаба файла нет
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
self.assertNotIn("retryable", out)
self.assertEqual(self.hub.file_requests, 1)
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
def test_corrupted_sha_is_retryable_hub_failure(self):
self.hub.files[SHA_ABC] = b"xyz" # размер тот же, содержимое другое
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
self.assertNotIn("AURORA-1", self.fake.attachments)
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
def test_length_mismatch_is_retryable_hub_failure(self):
self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=4)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertNotIn("AURORA-1", self.fake.attachments)
def test_hub_non_json_file_no_content_in_error(self):
def broken(sha):
raise agent.HttpError(200, 'ответ не JSON: Unterminated string; начало: {"file": "x", "content": "U0VDUkVULUNPTlRFTlQ')
self.ex.hub.file = broken
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertNotIn("U0VDUkVU", out["error"])
self.assertNotIn("начало", out["error"])
def test_hub_4xx_other_than_404_not_retryable(self):
self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "wrong-token", "timeout": 5})
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"])
self.assertFalse(out["retryable"])
def test_jira_413_failed_with_code_and_text(self):
self.fake.attachment_limit = 2
sha = self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("HTTP 413"), out["error"])
self.assertIn("Размер вложения превышает предел", out["error"])
self.assertFalse(out["retryable"])
def test_upload_response_without_id(self):
self.fake.bad_attachment_response = True
sha = self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "Jira не вернула id вложения в ответе на загрузку")
self.assertNotIn("retryable", out)
if __name__ == "__main__":
unittest.main()

View File

@ -48,6 +48,34 @@ class ExecutorFieldsTest(unittest.TestCase):
out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший") out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший")
self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный") self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный")
def test_ensure_issue_bug_by_name(self):
self.assertEqual(self.ex.issue_type_ref("bug"), {"name": "Ошибка"}) # id нет — по имени
self.assertEqual(self.ex.issue_type_ref("task"), {"id": "10100"}) # id есть — по id
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="ОСВ 60 не распознана",
labels=["AWPCA"])
self.assertEqual(out["status"], "done")
self.assertTrue(out["result"]["created"])
self.assertEqual(self.fake.issues[out["result"]["key"]]["issuetype"], "Ошибка")
def test_ensure_issue_type_not_configured_no_jira_calls(self):
self.ex.s = dict(SETTINGS, issue_types={"task": {"name": "Задача", "id": "10100"}})
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="x")
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "нет настройки issue_types.bug")
self.assertNotIn("retryable", out)
self.assertEqual(self.fake.requests, []) # ни поиска дубля, ни создания
def test_issue_type_entry_without_name_and_id(self):
for entry in ({}, {"name": None, "id": None}, {"name": "", "id": ""}, None, "Ошибка"):
self.ex.s = dict(SETTINGS, issue_types={"bug": entry})
with self.assertRaises(agent.ConfigGap) as cm:
self.ex.issue_type_ref("bug")
self.assertEqual(str(cm.exception), "нет настройки issue_types.bug")
self.ex.s = dict(SETTINGS)
del self.ex.s["issue_types"]
with self.assertRaises(agent.ConfigGap):
self.ex.issue_type_ref("task")
def test_ensure_fields_writes_only_diff(self): def test_ensure_fields_writes_only_diff(self):
self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний") self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний")
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний", out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний",

View File

@ -1,3 +1,4 @@
import base64
import io import io
import json import json
import os import os
@ -137,6 +138,30 @@ class FolderTransportTest(unittest.TestCase):
self.assertIsNone(hub.folder) self.assertIsNone(hub.folder)
self.assertEqual(hub.settings()["project_key"], "AURORA") self.assertEqual(hub.settings()["project_key"], "AURORA")
def test_file_through_folder(self):
self.run_relay()
data = os.urandom(300 * 1024)
sha = self.hub.put_file(data)
got = self.client().file(sha)
self.assertEqual((got["file"], got["size"]), (sha, len(data)))
self.assertEqual(base64.b64decode(got["content"]), data)
self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha)
self.assertEqual(self.relay.requests[0]["method"], "GET")
def test_file_missing_through_folder(self):
self.run_relay()
with self.assertRaises(agent.HttpError) as e:
self.client().file("0" * 64)
self.assertEqual(e.exception.code, 404)
self.assertIn("no_file", e.exception.text)
def test_file_over_http(self):
sha = self.hub.put_file(b"abc")
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
got = hub.file(sha)
self.assertEqual(base64.b64decode(got["content"]), b"abc")
self.assertEqual(self.hub.file_requests, 1)
class FolderIntegrationTest(unittest.TestCase): class FolderIntegrationTest(unittest.TestCase):
@ -157,8 +182,8 @@ class FolderIntegrationTest(unittest.TestCase):
def cfg(self): def cfg(self):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version_is_9(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 10) self.assertEqual(agent.AGENT_VERSION, 11)
def test_runner_through_folder(self): def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20") self.fake.add("AURORA-1", duedate="2026-10-20")

View File

@ -126,6 +126,56 @@ class RefreshTest(unittest.TestCase):
[{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}]) [{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}])
self.assertEqual(self.fake.requests, []) # без дочитывания self.assertEqual(self.fake.requests, []) # без дочитывания
def test_comment_without_id_dropped(self):
self.fake.add("AURORA-1")
self.fake.add_comment("AURORA-1", "нормальный")
self.fake.add_comment("AURORA-1", "второй")["id"] = None
self.fake.add_comment("AURORA-1", "третий")["id"] = ""
logs = []
orig = agent.log
agent.log = logs.append
try:
out = self.ex.run({"id": 10, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
finally:
agent.log = orig
self.assertEqual(out["status"], "done")
c = self.hub.states[0]["issues"][0]["comments"]
self.assertEqual([x["body"] for x in c], ["нормальный"])
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "комментарий без id" in l]), 2, logs)
def test_comments_read_denied_keeps_refresh(self):
for key in ("AURORA-1", "AURORA-2", "AURORA-3"):
self.fake.add(key)
for n in range(3):
self.fake.add_comment(key, "%s-%d" % (key, n))
self.fake.search_comment_cap = 1 # search отдаёт 1 из 3 — нужно дочитывание
self.fake.comment_denied = {"AURORA-1": 403, "AURORA-2": 404}
logs = []
orig = agent.log
agent.log = logs.append
try:
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-3"]}})
finally:
agent.log = orig
self.assertEqual(out["status"], "done")
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
self.assertNotIn("comments", by_key["AURORA-1"])
self.assertNotIn("comments", by_key["AURORA-2"])
self.assertEqual([x["body"] for x in by_key["AURORA-3"]["comments"]], ["AURORA-3-0", "AURORA-3-1", "AURORA-3-2"])
self.assertTrue(any("AURORA-1" in l and "HTTP 403" in l for l in logs), logs)
self.assertTrue(any("AURORA-2" in l and "HTTP 404" in l for l in logs), logs)
def test_comments_read_5xx_still_fails_refresh(self):
self.fake.add("AURORA-1")
for n in range(3):
self.fake.add_comment("AURORA-1", "к%d" % n)
self.fake.search_comment_cap = 1
self.fake.comment_denied = {"AURORA-1": 500}
out = self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"]) # 5xx — по-прежнему повтор через проверку (R-10)
self.assertEqual(self.hub.states, [])
def test_refresh_empty_jql_result(self): def test_refresh_empty_jql_result(self):
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done") self.assertEqual(out["status"], "done")

View File

@ -1,3 +1,6 @@
import base64
import hashlib
import json
import os import os
import tempfile import tempfile
import threading import threading
@ -580,6 +583,62 @@ class RunnerTest(unittest.TestCase):
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs) self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
self.assertNotIn("peer", r) self.assertNotIn("peer", r)
def test_attachment_upload_dropped_no_duplicate(self):
sha = self.hub.put_file(b"abc")
self.fake.drop_next_post_attachment = True # Jira сохранила вложение, ответ потерян
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "ОСВ 60.xlsx"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual(r["status"], "done", r)
self.assertTrue(r["result"]["existed"])
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
self.assertEqual(r["result"]["attachment_id"], self.fake.attachments["AURORA-1"][0]["id"])
self.assertEqual(self.hub.file_requests, 1) # повтор нашёл вложение, файл второй раз не нужен
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5)
self.assertEqual(r["jira_calls"], 2) # GET + повторный GET (оборванный POST не считается)
def test_attachment_corrupted_file_retried_as_hub_drop(self):
sha = hashlib.sha256(b"abc").hexdigest()
self.hub.files[sha] = b"xyz" # размер тот же, содержимое другое
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "a.txt"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual(r["status"], "failed")
self.assertTrue(r["error"].startswith("результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: "),
r["error"])
self.assertEqual(self.hub.file_requests, 4) # первый запуск + 3 повтора
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
self.assertEqual(self.fake.attachments, {})
def test_attachment_file_gone_at_claim(self):
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": "0" * 64, "filename": "a.txt"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual((r["status"], r["error"]), ("failed", "файл удалён с хаба, загрузите заново"))
self.assertEqual(r["jira_calls"], 0)
self.assertEqual(self.fake.requests, [])
def test_attachment_content_not_in_journal_or_log(self):
data = b"SECRET-CONTENT-" + bytes(range(256)) * 4
sha = self.hub.put_file(data)
secret_b64 = base64.b64encode(data).decode("ascii")
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "отчёт.bin"})
logs = []
orig = agent.log
agent.log = logs.append
try:
self.runner.loop()
finally:
agent.log = orig
self.assertEqual(self.hub.results[1]["status"], "done")
with open(self.journal.path, encoding="utf-8") as f:
text = f.read()
text += "\n".join(logs) + json.dumps(self.hub.results[1], ensure_ascii=False)
for needle in ("SECRET-CONTENT", secret_b64[:40], secret_b64[-40:]):
self.assertNotIn(needle, text)
self.assertTrue(any(sha in l and "отчёт.bin" in l for l in logs), logs) # file и filename — в логе
self.assertTrue(any(str(len(data)) in l for l in logs), logs) # размер — в логе
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()