From 3a5bd9a1096d191af65a0d9adad010494a267269 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 14:36:29 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=BE=20=E2=80=94=20=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B8,=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B9=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81,=20RE?= =?UTF-8?q?ADME=20v14=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По финальному ревью: M-1, M-4, M-6. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 8 ++++++-- src/agent.py | 22 ++++++++++++++++------ tests/fake_jira.py | 14 +++++++++++++- tests/test_clients.py | 25 +++++++++++++++++++++++-- tests/test_executor_attachments.py | 9 +++++++++ 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c1364c..6385e21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. - Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`), - `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`), `test_refresh.py` (`refresh`), + `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`), `test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429), `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки). diff --git a/README.md b/README.md index d1332eb..87b5653 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,8 @@ python3 agent.py prune | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | -| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | +| `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `/secure/attachment//<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется | +| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | Правила работы: - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. @@ -192,7 +193,7 @@ python3 agent.py prune `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `claim`, в каждом отчёте +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в @@ -205,6 +206,9 @@ python3 agent.py prune выкатывается только после v12 (design хаба `projects`, решение 14). Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`). + Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`); + перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на + `fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»). Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. diff --git a/src/agent.py b/src/agent.py index fee79e6..3007ff5 100644 --- a/src/agent.py +++ b/src/agent.py @@ -172,9 +172,12 @@ class Http: if content_type is not None: req.add_header("Content-Type", content_type) try: - with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: + with self._opener().open(req, timeout=timeout or self.timeout) as resp: raw = resp.read() status = resp.status + except RedirectAway as e: + self._touch() + raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None except urllib.error.HTTPError as e: self._touch() try: @@ -193,6 +196,14 @@ class Http: except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None + def _opener(self): + """Открыватель запросов: перенаправления — только на адрес клиента (urllib переносит Authorization и на + чужой хост); контекст TLS — из настроек.""" + handlers = [_SameOriginRedirect(self.base)] # type: List[Any] + if self.ctx is not None: + handlers.append(urllib.request.HTTPSHandler(context=self.ctx)) + return urllib.request.build_opener(*handlers) + def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes: """GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send.""" @@ -200,13 +211,12 @@ class Http: for k, v in self.headers.items(): req.add_header(k, v) req.add_header("Accept", "*/*") - handlers = [_SameOriginRedirect(self.base)] # type: List[Any] - if self.ctx is not None: - handlers.append(urllib.request.HTTPSHandler(context=self.ctx)) - opener = urllib.request.build_opener(*handlers) try: - with opener.open(req, timeout=timeout or self.timeout) as resp: + with self._opener().open(req, timeout=timeout or self.timeout) as resp: data = resp.read(limit + 1) + rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше + if rest and len(data) <= limit: + raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest)) except RedirectAway: self._touch() raise diff --git a/tests/fake_jira.py b/tests/fake_jira.py index c807bf2..bbbe930 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -90,6 +90,8 @@ class FakeJira: self.content_gets = 0 # GET /secure/attachment//<имя> — запросы содержимого self.redirect_content = None # адрес для 302 на запрос содержимого (абсолютный или путь этого сервера) self.short_content = set() # id вложений, содержимое которых отдаётся без последнего байта + self.lying_length = set() # id вложений: Content-Length больше тела, соединение рвётся после тела + self.redirects = {} # путь → адрес Location: 302 вместо ответа (любой метод) self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] @@ -263,6 +265,12 @@ class FakeJira: code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} except Exception as e: # некорректный запрос к фейку code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]} + if url.path in self.redirects: + h.send_response(302) + h.send_header("Location", self.redirects[url.path]) + h.send_header("Content-Length", "0") + h.end_headers() + return if method == "GET" and (url.path.startswith("/secure/attachment/") or url.path.startswith("/secure/moved/")): return self._content(h, url.path) if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue": @@ -301,9 +309,13 @@ class FakeJira: data = found["data"][:-1] if found["id"] in self.short_content else found["data"] h.send_response(200) h.send_header("Content-Type", found["mimeType"]) - h.send_header("Content-Length", str(len(data))) + h.send_header("Content-Length", str(len(data) + (60 if found["id"] in self.lying_length else 0))) h.end_headers() h.wfile.write(data) + if found["id"] in self.lying_length: + h.wfile.flush() + h.close_connection = True + h.connection.shutdown(socket.SHUT_RDWR) # обрыв посреди тела def _send(self, h, code, payload, headers=None): data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" diff --git a/tests/test_clients.py b/tests/test_clients.py index 8349677..55ad69e 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -133,6 +133,27 @@ class ClientsTest(unittest.TestCase): self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc") self.assertEqual(self.jira.rate_limited, 1) + def test_download_cut_body_is_connection_error(self): # M-1 финального ревью + self.fake.add("AURORA-1") + a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 40) + self.fake.lying_length.add(a["id"]) + with self.assertRaises(ConnectionError): + self.jira.download(self._content_path(a), 1000) + + def test_json_call_redirect_other_host_refused(self): # M-4 финального ревью + self.fake.add("AURORA-1") + port = self.fake.server.server_port + self.fake.redirects["/rest/api/2/issue/AURORA-1"] = "http://localhost:%d/rest/api/2/issue/AURORA-2" % port + with self.assertRaises(agent.HttpError) as e: + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertIn("localhost", e.exception.text) + self.assertFalse(any(r[1] == "/rest/api/2/issue/AURORA-2" for r in self.fake.requests)) + + def test_json_call_redirect_same_host_followed(self): + self.fake.add("AURORA-2") + self.fake.redirects["/rest/api/2/issue/AURORA-1"] = self.fake.base_url + "/rest/api/2/issue/AURORA-2" + self.assertEqual(self.jira.get("/rest/api/2/issue/AURORA-1")["key"], "AURORA-2") + def test_retry_on_429_with_retry_after(self): self.fake.add("AURORA-1") self.fake.fail_next.append((429, {"Retry-After": "0"})) @@ -196,7 +217,7 @@ class ClientsTest(unittest.TestCase): raise TimeoutError("timed out") err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken()) - with mock.patch("urllib.request.urlopen", side_effect=err): + with mock.patch("urllib.request.OpenerDirector.open", side_effect=err): with self.assertRaises(ConnectionError): self.jira.get("/x") self.assertEqual(self.jira.calls, 1) @@ -204,7 +225,7 @@ class ClientsTest(unittest.TestCase): def test_incomplete_read_is_connection_error(self): import http.client from unittest import mock - with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)): + with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)): with self.assertRaises(ConnectionError): self.jira.get("/x") diff --git a/tests/test_executor_attachments.py b/tests/test_executor_attachments.py index 009f55d..4269d5d 100644 --- a/tests/test_executor_attachments.py +++ b/tests/test_executor_attachments.py @@ -242,3 +242,12 @@ class FetchAttachmentTest(unittest.TestCase): self.assertEqual(out["status"], "failed") self.assertFalse(out["retryable"]) self.assertTrue(out["error"].startswith("хаб: "), out["error"]) + + def test_fetch_unknown_size_cut_body_retryable(self): # M-1 финального ревью + a = self.fake.add_attachment("AURORA-1650", "a.json", b"x" * 40) + self.fake.hide_size.add(a["id"]) + self.fake.lying_length.add(a["id"]) + out = self.fetch(a) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) + self.assertEqual(self.hub.uploaded, [])