Агент v14: список вложений в состоянии refresh (#43)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:10:34 +04:00
parent ceadd9c071
commit 0d72369875
5 changed files with 81 additions and 11 deletions

View File

@ -33,7 +33,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 13 AGENT_VERSION = 14
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@ -941,7 +941,7 @@ class Executor:
# --- refresh --------------------------------------------------------------- # --- refresh ---------------------------------------------------------------
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", 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: def state_fields(self) -> str:
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] 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_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, "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, "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]: def links_from(self, raw: dict) -> List[dict]:
key, out = raw["key"], [] 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, 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 ""} "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]]: def comments_of(self, raw: dict) -> Optional[List[dict]]:
"""Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично
через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30); через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30);

View File

@ -6,7 +6,7 @@ import socket
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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)) MSK = timezone(timedelta(hours=3))
@ -85,6 +85,8 @@ class FakeJira:
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
self.bad_attachment_response = False # ответить на загрузку пустым списком self.bad_attachment_response = False # ответить на загрузку пустым списком
self.hide_size = set() # id вложений без size в JSON (Jira не отдала размер)
self.hide_attachment_field = False # поле attachment скрыто настройками Jira
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
@ -162,15 +164,23 @@ class FakeJira:
self.comments.setdefault(key, []).append(c) self.comments.setdefault(key, []).append(c)
return 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 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) self.attachments.setdefault(key, []).append(a)
return a return a
def attachment_json(self, a): def attachment_json(self, a):
return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"], 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"]} "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): def writes(self):
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] 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] 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), out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
"comments": shown} "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, [])] out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
return {"key": key, "id": f["id"], "fields": out} return {"key": key, "id": f["id"], "fields": out}

View File

@ -183,7 +183,7 @@ class FolderIntegrationTest(unittest.TestCase):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 13) self.assertEqual(agent.AGENT_VERSION, 14)
def test_runner_through_folder(self): def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20") self.fake.add("AURORA-1", duedate="2026-10-20")

View File

@ -310,7 +310,7 @@ class RefreshHistoryTest(Base):
self.assertEqual(self.changelog_queries(), []) self.assertEqual(self.changelog_queries(), [])
def test_version(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 13) self.assertEqual(agent.AGENT_VERSION, 14)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -105,6 +105,45 @@ class RefreshTest(unittest.TestCase):
self.assertEqual(len(searches), 1) # комментарии пришли тем же search self.assertEqual(len(searches), 1) # комментарии пришли тем же search
self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests)) 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): def test_comments_partial_list_is_read_fully(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
for n in range(130): for n in range(130):