Агент v14: список вложений в состоянии refresh (#43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ceadd9c071
commit
0d72369875
27
src/agent.py
27
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);
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user