Агент 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 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);

View File

@ -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}

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)
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")

View File

@ -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__":

View File

@ -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):