From 0d72369875b5a35e6293110c6bdedcd8237caa81 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 14:10:34 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82=20v14:=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B2=20=D1=81=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B8=20refresh=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 27 ++++++++++++++++++++++++--- tests/fake_jira.py | 22 ++++++++++++++++------ tests/test_folder.py | 2 +- tests/test_history.py | 2 +- tests/test_refresh.py | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/agent.py b/src/agent.py index 1fd9da8..e33c3b4 100644 --- a/src/agent.py +++ b/src/agent.py @@ -33,7 +33,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 13 +AGENT_VERSION = 14 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__)) @@ -941,7 +941,7 @@ class Executor: # --- refresh --------------------------------------------------------------- STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", - "issuetype", "created", "updated", "issuelinks", "comment"] + "issuetype", "created", "updated", "issuelinks", "comment", "attachment"] def state_fields(self) -> str: extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] @@ -995,7 +995,7 @@ class Executor: "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, - "created": f.get("created"), "jira_updated": f.get("updated")} + "created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)} def links_from(self, raw: dict) -> List[dict]: key, out = raw["key"], [] @@ -1044,6 +1044,27 @@ 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 ""} + @staticmethod + def attachment_state(a: dict) -> dict: + """Вложение для хаба — без содержимого: автор как у комментария, размер — целое или None, время Jira как есть.""" + u = a.get("author") or {} + size = a.get("size") + return {"id": str(a.get("id")), "filename": a.get("filename") or "", + "size": size if isinstance(size, int) and not isinstance(size, bool) and size >= 0 else None, + "mime_type": a.get("mimeType") or None, "author": u.get("displayName") or u.get("name") or None, + "created": a.get("created")} + + def attachments_of(self, raw: dict) -> List[dict]: + """Вложения задачи из ответа search (поле attachment отдаётся целиком). Нет поля — []; вложение без id или без + имени отбрасывается с записью в лог: хаб такую пачку не примет.""" + out = [] + for a in (raw.get("fields") or {}).get("attachment") or []: + if not isinstance(a, dict) or not a.get("id") or not a.get("filename"): + log(" ! %s: вложение без id или имени пропущено" % raw.get("key")) + continue + out.append(self.attachment_state(a)) + return out + def comments_of(self, raw: dict) -> Optional[List[dict]]: """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30); diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 4a4b7a9..38be673 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, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse MSK = timezone(timedelta(hours=3)) @@ -85,6 +85,8 @@ class FakeJira: self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа self.attachment_limit = None # байт: больше — 413, как предел вложений Jira self.bad_attachment_response = False # ответить на загрузку пустым списком + self.hide_size = set() # id вложений без size в JSON (Jira не отдала размер) + self.hide_attachment_field = False # поле attachment скрыто настройками Jira self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] @@ -162,15 +164,23 @@ class FakeJira: self.comments.setdefault(key, []).append(c) return c - def add_attachment(self, key, filename, data, mime="application/octet-stream"): + def add_attachment(self, key, filename, data, mime="application/octet-stream", name="potapova", display="Потапова Анна", + created="2026-10-20T09:15:00.000+0300"): self.attachment_seq += 1 - a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data} + author = {"name": name, "displayName": display} if display else ({"name": name} if name else None) + a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data, + "author": author, "created": created} 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"]} + out = {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"], + "filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"], "author": a.get("author"), + "created": a.get("created"), + "content": "%s/secure/attachment/%s/%s" % (self.base_url, a["id"], quote(a["filename"]))} + if a["id"] in self.hide_size: + del out["size"] + return out def writes(self): return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] @@ -193,7 +203,7 @@ 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: + if (fields is None or "attachment" in fields) and not self.hide_attachment_field: out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])] return {"key": key, "id": f["id"], "fields": out} diff --git a/tests/test_folder.py b/tests/test_folder.py index af7840f..cc5f2ac 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -183,7 +183,7 @@ class FolderIntegrationTest(unittest.TestCase): return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 13) + self.assertEqual(agent.AGENT_VERSION, 14) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") diff --git a/tests/test_history.py b/tests/test_history.py index 42955e1..ba9f754 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -310,7 +310,7 @@ class RefreshHistoryTest(Base): self.assertEqual(self.changelog_queries(), []) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 13) + self.assertEqual(agent.AGENT_VERSION, 14) if __name__ == "__main__": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index bda5174..3c336f6 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -105,6 +105,45 @@ class RefreshTest(unittest.TestCase): self.assertEqual(len(searches), 1) # комментарии пришли тем же search self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests)) + def test_refresh_attachments_in_state(self): # сценарий «Вложения в состоянии» + self.fake.add("AURORA-1650") + self.fake.add_attachment("AURORA-1650", "скрин 1.png", b"x" * 240000, mime="image/png") + self.fake.add_attachment("AURORA-1650", "ОСВ 60.xlsx", b"y" * 10, name="ivanov", display=None) + out = self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1650"]}}) + self.assertEqual(out["status"], "done") + st = self.hub.states[0]["issues"][0] + a = st["attachments"] + self.assertEqual([x["filename"] for x in a], ["скрин 1.png", "ОСВ 60.xlsx"]) + self.assertEqual(a[0], {"id": a[0]["id"], "filename": "скрин 1.png", "size": 240000, "mime_type": "image/png", + "author": "Потапова Анна", "created": "2026-10-20T09:15:00.000+0300"}) + self.assertIsInstance(a[0]["id"], str) + self.assertEqual(a[1]["author"], "ivanov") + self.assertIn("attachment", self.ex.state_fields().split(",")) + self.assertEqual(sum(1 for r in self.fake.requests if r[1].endswith("/search")), 1) + self.assertFalse(any("/secure/attachment/" in r[1] for r in self.fake.requests)) + + def test_refresh_attachment_without_id_dropped(self): + self.fake.add("AURORA-1") + self.fake.add_attachment("AURORA-1", "a.txt", b"a") + self.fake.add_attachment("AURORA-1", "b.txt", b"b") + self.fake.attachments["AURORA-1"][0]["id"] = None + self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + a = self.hub.states[0]["issues"][0]["attachments"] + self.assertEqual([x["filename"] for x in a], ["b.txt"]) + + def test_refresh_no_attachments_empty_list(self): + self.fake.add("AURORA-1") + self.fake.add("AURORA-2") + self.fake.add_attachment("AURORA-2", "a.txt", b"a") + self.fake.attachments["AURORA-2"][0]["size"] = None + self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}}) + by_key = {i["key"]: i for i in self.hub.states[0]["issues"]} + self.assertEqual(by_key["AURORA-1"]["attachments"], []) + self.assertIsNone(by_key["AURORA-2"]["attachments"][0]["size"]) + self.fake.hide_attachment_field = True + self.ex.run({"id": 6, "type": "refresh", "params": {"keys": ["AURORA-2"]}}) + self.assertEqual(self.hub.states[-1]["issues"][0]["attachments"], []) + def test_comments_partial_list_is_read_fully(self): self.fake.add("AURORA-1") for n in range(130):