Агент v11: тип «Ошибка», ensure_attachment, устойчивое дочитывание комментариев (#31) #32
@ -8,10 +8,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
| Раздел | Классы и функции | Ответственность | Что использует |
|
||||
|---|---|---|---|
|
||||
| Настройки | `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` |
|
||||
| Журнал | `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` |
|
||||
| Цикл | `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`).
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения).
|
||||
`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_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_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/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`.
|
||||
|
||||
## Выкатка новой версии
|
||||
|
||||
10
README.md
10
README.md
@ -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_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
|
||||
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
|
||||
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
||||
| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог |
|
||||
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
||||
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет |
|
||||
|
||||
Правила работы:
|
||||
@ -189,11 +190,14 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 9) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 11) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
||||
`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`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
@ -238,6 +242,8 @@ python3 agent.py prune
|
||||
С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время
|
||||
и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через
|
||||
`/issue/{key}/comment`.
|
||||
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
|
||||
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
|
||||
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
|
||||
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
|
||||
завершающий перевод строки) — изменение.
|
||||
|
||||
1395
docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md
Normal file
1395
docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-16
|
||||
29
openspec/changes/agent-v11-bug-attachments/design.md
Normal file
29
openspec/changes/agent-v11-bug-attachments/design.md
Normal 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` запускались в фоне.
|
||||
150
src/agent.py
150
src/agent.py
@ -31,9 +31,9 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
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_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
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):
|
||||
def __init__(self, code: int, text: str, headers=None):
|
||||
super().__init__("HTTP %d: %s" % (code, text[:300]))
|
||||
@ -112,16 +126,22 @@ class Http:
|
||||
return "<%s %s>" % (self.__class__.__name__, self.base)
|
||||
|
||||
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
|
||||
if params:
|
||||
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)
|
||||
for k, v in self.headers.items():
|
||||
req.add_header(k, v)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||
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:
|
||||
raw = resp.read()
|
||||
@ -184,10 +204,11 @@ class JiraClient(Http):
|
||||
def reset_counters(self) -> None:
|
||||
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):
|
||||
try:
|
||||
return super().call(method, path, body, params)
|
||||
return request()
|
||||
except HttpError as e:
|
||||
if e.code in (429, 503) and attempt < 5:
|
||||
wait = 60 * attempt
|
||||
@ -203,6 +224,18 @@ class JiraClient(Http):
|
||||
self.rate_limited += 1
|
||||
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):
|
||||
return self.call("GET", path, params=params or None)
|
||||
|
||||
@ -330,6 +363,10 @@ class HubClient(Http):
|
||||
def known_keys(self) -> List[str]:
|
||||
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 команды
|
||||
@ -512,6 +549,9 @@ class HubError(Exception):
|
||||
self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500)
|
||||
|
||||
|
||||
FILE_GONE = "файл удалён с хаба, загрузите заново"
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
|
||||
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}
|
||||
|
||||
def issue_type_ref(self, kind: str) -> dict:
|
||||
it = (self.s.get("issue_types") or {}).get(kind) or {}
|
||||
return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")}
|
||||
"""Тип задачи Jira из issue_types.<kind>: {id}, если id задан, иначе {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:
|
||||
ep = self.s.get("epic_link") or {}
|
||||
@ -593,6 +639,7 @@ class Executor:
|
||||
|
||||
# --- ensure_issue ----------------------------------------------------------
|
||||
def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
|
||||
issuetype = self.issue_type_ref(p["issue_type"]) # до поиска дубля: без настройки — ни одного вызова Jira
|
||||
summary = (p["summary"] or "").strip()
|
||||
jql = "project = %s AND reporter = currentUser() AND created >= -7d ORDER BY created DESC" % self.s["project_key"]
|
||||
start = 0
|
||||
@ -605,8 +652,7 @@ class Executor:
|
||||
start += len(issues)
|
||||
if not issues or start >= int(found.get("total") or 0):
|
||||
break
|
||||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]),
|
||||
"summary": p["summary"]}
|
||||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": issuetype, "summary": p["summary"]}
|
||||
if p.get("description") is not None:
|
||||
fields[self.fid("description")] = p["description"]
|
||||
if p.get("priority"):
|
||||
@ -825,6 +871,50 @@ class Executor:
|
||||
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
||||
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 ---------------------------------------------------------------
|
||||
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
|
||||
"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,
|
||||
"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) — дочитать постранично
|
||||
через /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 {}
|
||||
got = list(block.get("comments") or [])
|
||||
total = block.get("total")
|
||||
if total is not None and len(got) < int(total):
|
||||
got, start = [], 0
|
||||
while True:
|
||||
page = self.jira.get(API + "/issue/%s/comment" % raw["key"], startAt=start, maxResults=100)
|
||||
part = page.get("comments") or []
|
||||
got += part
|
||||
start += len(part)
|
||||
if not part or page.get("total") is None or start >= int(page["total"]):
|
||||
break
|
||||
return [self.comment_state(c) for c in got]
|
||||
try:
|
||||
while True:
|
||||
page = self.jira.get(API + "/issue/%s/comment" % key, startAt=start, maxResults=100)
|
||||
part = page.get("comments") or []
|
||||
got += part
|
||||
start += len(part)
|
||||
if not part or page.get("total") is None or start >= int(page["total"]):
|
||||
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]:
|
||||
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет,
|
||||
@ -969,7 +1073,9 @@ class Executor:
|
||||
for r in everything:
|
||||
st = self.to_state(r)
|
||||
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)
|
||||
for l in self.links_from(r):
|
||||
sig = (l["source_key"], l["target_key"], l["link_type"])
|
||||
|
||||
@ -9,12 +9,13 @@ if SRC not in sys.path:
|
||||
import agent # noqa: E402
|
||||
|
||||
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 = {
|
||||
"base_jql": "project = AURORA AND labels = AWPCA",
|
||||
"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",
|
||||
"field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels",
|
||||
"planned_start": "customfield_10801", "planned_end": "customfield_10802"},
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
@ -23,6 +25,8 @@ class FakeHub:
|
||||
self.in_flight = None
|
||||
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||
self.release_requests = 0
|
||||
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
|
||||
self.file_requests = 0
|
||||
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||
self.lock = threading.Lock()
|
||||
hub = self
|
||||
@ -54,6 +58,19 @@ class FakeHub:
|
||||
self.queue.append(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):
|
||||
r = self.release
|
||||
return {"version": r["version"], "sha256": r["sha256"]} if r else None
|
||||
@ -97,7 +114,7 @@ class FakeHub:
|
||||
self.commands[cid]["status"] = "claimed"
|
||||
self.in_flight = 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()}
|
||||
if path == "/agent/release":
|
||||
self.release_requests += 1
|
||||
@ -109,10 +126,17 @@ class FakeHub:
|
||||
if path == "/agent/state":
|
||||
self.states.append(body)
|
||||
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)
|
||||
if m:
|
||||
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)
|
||||
if m:
|
||||
cid = int(m.group(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, urlparse
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
MSK = timezone(timedelta(hours=3))
|
||||
|
||||
@ -24,6 +24,43 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал
|
||||
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:
|
||||
def __init__(self, project="AURORA"):
|
||||
self.project = project
|
||||
@ -38,6 +75,14 @@ class FakeJira:
|
||||
self.requests = [] # [(метод, путь)]
|
||||
self.queries = [] # параметры запросов (q) — для проверки JQL
|
||||
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.lock = threading.Lock()
|
||||
fake = self
|
||||
@ -93,6 +138,16 @@ class FakeJira:
|
||||
self.comments.setdefault(key, []).append(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):
|
||||
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]
|
||||
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:
|
||||
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
||||
return {"key": key, "id": f["id"], "fields": out}
|
||||
|
||||
def links_of(self, key):
|
||||
@ -148,8 +205,14 @@ class FakeJira:
|
||||
url = urlparse(h.path)
|
||||
q = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
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:
|
||||
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
|
||||
self.requests.append((method, url.path))
|
||||
self.queries.append(q)
|
||||
if self.fail_next:
|
||||
@ -166,6 +229,11 @@ class FakeJira:
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит
|
||||
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)
|
||||
|
||||
def _send(self, h, code, payload, headers=None):
|
||||
@ -234,6 +302,8 @@ class FakeJira:
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
if method == "GET" and key in self.comment_denied:
|
||||
return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]}
|
||||
self.issues[key]
|
||||
lst = self.comments.setdefault(key, [])
|
||||
if method == "GET":
|
||||
@ -250,6 +320,26 @@ class FakeJira:
|
||||
if c["id"] == m.group(2):
|
||||
c["body"] = body["body"]
|
||||
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)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
from _common import agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
@ -21,6 +22,74 @@ class ClientsTest(unittest.TestCase):
|
||||
self.assertIsNotNone(self.jira.last_call_at)
|
||||
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):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
|
||||
141
tests/test_executor_attachments.py
Normal file
141
tests/test_executor_attachments.py
Normal 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()
|
||||
@ -48,6 +48,34 @@ class ExecutorFieldsTest(unittest.TestCase):
|
||||
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"], "Критичный")
|
||||
|
||||
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):
|
||||
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="Средний",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@ -137,6 +138,30 @@ class FolderTransportTest(unittest.TestCase):
|
||||
self.assertIsNone(hub.folder)
|
||||
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):
|
||||
@ -157,8 +182,8 @@ class FolderIntegrationTest(unittest.TestCase):
|
||||
def cfg(self):
|
||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||
|
||||
def test_version_is_9(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 10)
|
||||
def test_version(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 11)
|
||||
|
||||
def test_runner_through_folder(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||
|
||||
@ -126,6 +126,56 @@ class RefreshTest(unittest.TestCase):
|
||||
[{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}])
|
||||
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):
|
||||
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
@ -580,6 +583,62 @@ class RunnerTest(unittest.TestCase):
|
||||
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user