diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 388fbdb..509416d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ 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/.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` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | diff --git a/src/agent.py b/src/agent.py index 37ea8d8..34e4535 100644 --- a/src/agent.py +++ b/src/agent.py @@ -31,7 +31,7 @@ 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 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) @@ -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 команды diff --git a/tests/fake_hub.py b/tests/fake_hub.py index fe2c263..68d795c 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -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)) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index b3e2813..87bd154 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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 @@ -39,6 +76,13 @@ class FakeJira: 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 @@ -94,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")] @@ -115,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): @@ -149,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: @@ -167,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): @@ -253,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) diff --git a/tests/test_clients.py b/tests/test_clients.py index 5f88271..568fd6f 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -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"})) diff --git a/tests/test_folder.py b/tests/test_folder.py index 2018c31..9b2e6b9 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -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):