From 4819950738fd7ed8ef64ed1b15cfba2c0bbb8e06 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 14:19:50 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82=20v14:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20fetch=5Fattachment=20(#4?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +- CLAUDE.md | 4 +- src/agent.py | 38 +++++++++++ tests/test_executor_attachments.py | 103 +++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8b09ddf..84fd40a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,10 +8,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл | Раздел | Классы и функции | Ответственность | Что использует | |---|---|---|---| | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | -| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` | +| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | -| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | | Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` | diff --git a/CLAUDE.md b/CLAUDE.md index f634c32..4c1364c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`, `cmd_selftest`, `cmd_run`, `cmd_prune`, `main`). - `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует. -- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения). +- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, `ensure_attachment`, где у хаба берётся файл вложения, и `fetch_attachment`, где скачанный из Jira файл отдаётся хабу). `Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira. - `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. - Подробнее: `ARCHITECTURE.md`. @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`. -Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`. +Последний change — `../jira-hub/openspec/changes/attachment-fetch/` (агент v14: список вложений в `refresh`, команда `fetch_attachment`; решения агента — `openspec/changes/agent-v14-attachment-fetch/design.md`), план — `docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md`. План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. ## Выкатка новой версии diff --git a/src/agent.py b/src/agent.py index 7a8d0fe..fee79e6 100644 --- a/src/agent.py +++ b/src/agent.py @@ -645,6 +645,18 @@ class HubError(Exception): FILE_GONE = "файл удалён с хаба, загрузите заново" +MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт) + + +def _fmt_size(n: Optional[int]) -> str: + """Размер для текстов ошибок: «N Б», «N,N КБ», «N,N МБ» (по 1024, запятая), как у хаба.""" + if n is None: + return "размер неизвестен" + if n < 1024: + return "%d Б" % n + if n < 1024 * 1024: + return ("%.1f КБ" % (n / 1024)).replace(".", ",") + return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",") class Executor: @@ -1010,6 +1022,32 @@ class Executor: return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку") return self._out("done", {"attachment_id": str(first["id"]), "created": True}) + def do_fetch_attachment(self, p: dict, cmd: dict) -> dict: + """Файл вложения из Jira — хабу (POST /agent/files). В Jira не пишет; адрес содержимого собирается из base_url, + перенаправление на другой адрес — отказ (учётные данные туда не уходят). Содержимое не логируется.""" + key, aid = p["key"], str(p["attachment_id"]) + issue = self.jira.get(API + "/issue/%s" % key, fields="attachment") + found = [a for a in (issue.get("fields") or {}).get("attachment") or [] if str(a.get("id")) == aid] + if not found: + return self._out("failed", error="вложение #%s удалено из %s в Jira" % (aid, key)) + filename, size = found[0].get("filename") or "", found[0].get("size") + size = size if isinstance(size, int) and not isinstance(size, bool) else None + too_big = "файл больше 10 МБ (%s) — скачайте в Jira" + if size is not None and size > MAX_ATTACHMENT: + return self._out("failed", error=too_big % _fmt_size(size)) + try: + data = self.jira.download("/secure/attachment/%s/%s" % (aid, urllib.parse.quote(filename, safe="")), + MAX_ATTACHMENT) + except TooBig: + return self._out("failed", error=too_big % ("больше " + _fmt_size(MAX_ATTACHMENT))) + except RedirectAway as e: + return self._out("failed", error="Jira перенаправила скачивание на %s" % e.host) + if size is not None and len(data) != size: + return self._out("failed", error="файл пришёл не целиком (%d из %d)" % (len(data), size)) + log(" вложение #%s «%s» (%s) ← %s" % (aid, filename, _fmt_size(len(data)), key)) + got = self._hub(self.hub.upload_file, data) + return self._out("done", {"attachment_id": aid, "file": got.get("file"), "size": got.get("size")}) + # --- refresh --------------------------------------------------------------- STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", "issuetype", "created", "updated", "issuelinks", "comment", "attachment"] diff --git a/tests/test_executor_attachments.py b/tests/test_executor_attachments.py index 1aa352c..009f55d 100644 --- a/tests/test_executor_attachments.py +++ b/tests/test_executor_attachments.py @@ -139,3 +139,106 @@ class EnsureAttachmentTest(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class FetchAttachmentTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.jira = agent.JiraClient(cfg) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg)) + self.ex.sleep = no_sleep + self.fake.add("AURORA-1650") + + def tearDown(self): + self.fake.stop() + self.hub.stop() + + def fetch(self, a, key="AURORA-1650"): + return self.ex.run({"id": 7, "type": "fetch_attachment", "params": {"key": key, "attachment_id": a["id"]}}) + + def test_fetch_screenshot(self): # сценарий «Скачивание скриншота» + data = bytes(range(256)) * 900 + a = self.fake.add_attachment("AURORA-1650", "скрин 1.png", data, mime="image/png") + out = self.fetch(a) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"], {"attachment_id": a["id"], "file": hashlib.sha256(data).hexdigest(), + "size": len(data)}) + self.assertEqual(self.hub.uploaded, [data]) + self.assertEqual(self.fake.requests[0], ("GET", "/rest/api/2/issue/AURORA-1650")) + self.assertEqual(self.fake.queries[0].get("fields"), "attachment") + self.assertEqual(self.fake.requests[1][1], + "/secure/attachment/%s/%%D1%%81%%D0%%BA%%D1%%80%%D0%%B8%%D0%%BD%%201.png" % a["id"]) + self.assertEqual(self.fake.writes(), []) + + def test_fetch_deleted(self): + a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc") + self.fake.attachments["AURORA-1650"] = [] + out = self.fetch(a) + self.assertEqual((out["status"], out["error"]), + ("failed", "вложение #%s удалено из AURORA-1650 в Jira" % a["id"])) + self.assertEqual(self.fake.content_gets, 0) + + def test_fetch_too_large_known(self): + a = self.fake.add_attachment("AURORA-1650", "запись.mp4", b"x") + a["size"] = 25000000 + out = self.fetch(a) + self.assertEqual((out["status"], out["error"]), + ("failed", "файл больше 10 МБ (23,8 МБ) — скачайте в Jira")) + self.assertEqual(self.fake.content_gets, 0) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_too_large_unknown(self): + a = self.fake.add_attachment("AURORA-1650", "big.bin", b"x" * (12 * 1024 * 1024)) + self.fake.hide_size.add(a["id"]) + out = self.fetch(a) + self.assertEqual((out["status"], out["error"]), + ("failed", "файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira")) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_unknown_size_ok(self): + a = self.fake.add_attachment("AURORA-1650", "a.json", b'{"a": 1}', mime="application/json") + self.fake.hide_size.add(a["id"]) + out = self.fetch(a) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 8) + + def test_fetch_redirect_away(self): + a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc") + self.fake.redirect_content = "http://localhost:%d/secure/moved/%s/a.png" % (self.fake.server.server_port, a["id"]) + out = self.fetch(a) + self.assertEqual((out["status"], out["error"]), ("failed", "Jira перенаправила скачивание на localhost")) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_truncated(self): + a = self.fake.add_attachment("AURORA-1650", "a.bin", b"abcdef") + self.fake.short_content.add(a["id"]) + out = self.fetch(a) + self.assertEqual((out["status"], out["error"]), ("failed", "файл пришёл не целиком (5 из 6)")) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_empty(self): + a = self.fake.add_attachment("AURORA-1650", "empty.txt", b"") + out = self.fetch(a) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 0) + self.assertEqual(self.hub.uploaded, [b""]) + + def test_fetch_hub_down(self): + a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc") + self.hub.reject_files = "drop" + out = self.fetch(a) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) + self.assertEqual(out["peer"], "хабом") + self.assertTrue(out["error"].startswith("хаб: ")) + + def test_fetch_hub_422(self): + a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc") + self.hub.reject_files = (422, {"error": "bad_request", "detail": "content — не base64"}) + out = self.fetch(a) + self.assertEqual(out["status"], "failed") + self.assertFalse(out["retryable"]) + self.assertTrue(out["error"].startswith("хаб: "), out["error"])