diff --git a/src/agent.py b/src/agent.py index 4118dfb..1d7bb50 100644 --- a/src/agent.py +++ b/src/agent.py @@ -827,7 +827,7 @@ class Executor: # --- refresh --------------------------------------------------------------- STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", - "issuetype", "created", "updated", "issuelinks"] + "issuetype", "created", "updated", "issuelinks", "comment"] def state_fields(self) -> str: extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)] @@ -893,6 +893,30 @@ class Executor: out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t}) return out + @staticmethod + def comment_state(c: dict) -> dict: + """Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira.""" + a = c.get("author") or {} + 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 ""} + + def comments_of(self, raw: dict) -> List[dict]: + """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично + через /issue/{key}/comment и взять полный список вместо частичного.""" + block = (raw.get("fields") or {}).get("comment") or {} + got = list(block.get("comments") or []) + total = block.get("total") + if total is not None and len(got) < int(total): + got, start = [], 0 + while True: + page = self.jira.get(API + "/issue/%s/comment" % raw["key"], startAt=start, maxResults=100) + part = page.get("comments") or [] + got += part + start += len(part) + if not part or page.get("total") is None or start >= int(page["total"]): + break + return [self.comment_state(c) for c in got] + def sprint_names(self, keys: List[str]) -> Dict[str, str]: # Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, # поэтому у задач закрытых спринтов sprint = None (принятое ограничение). @@ -945,6 +969,7 @@ class Executor: for r in everything: st = self.to_state(r) st["sprint"] = names.get(r["key"]) + st["comments"] = self.comments_of(r) issues.append(st) for l in self.links_from(r): sig = (l["source_key"], l["target_key"], l["link_type"]) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 9ce8590..8f4488e 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -38,6 +38,7 @@ class FakeJira: self.requests = [] # [(метод, путь)] self.queries = [] # параметры запросов (q) — для проверки JQL self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа + self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.lock = threading.Lock() fake = self @@ -84,6 +85,14 @@ class FakeJira: self.issues[key] = base return base + def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300", + updated=None): + author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None + self.comment_seq += 1 + c = {"id": str(self.comment_seq), "body": body, "author": author, "created": created, "updated": updated or created} + self.comments.setdefault(key, []).append(c) + return c + def writes(self): return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] @@ -91,14 +100,21 @@ class FakeJira: def _user(self, name): return {"name": name, "displayName": name} if name else None - def issue_json(self, key): + def issue_json(self, key, fields=None): + """fields — список запрошенных полей (как параметр fields у search); None — все поля, как GET /issue/{key}.""" f = self.issues[key] - fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, - "priority": {"name": f["priority"], "id": int(f["priority"]) if str(f["priority"]).isdigit() else f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), - "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, - "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} - fields.update({c: f.get(c) for c in CUSTOM}) - return {"key": key, "id": f["id"], "fields": fields} + out = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, + "priority": {"name": f["priority"], "id": int(f["priority"]) if str(f["priority"]).isdigit() else f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), + "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, + "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} + out.update({c: f.get(c) for c in CUSTOM}) + if fields is None or "comment" in fields: + lst = self.comments.get(key, []) + cap = self.search_comment_cap if fields is not None else None + 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} + return {"key": key, "id": f["id"], "fields": out} def links_of(self, key): out = [] @@ -173,7 +189,9 @@ class FakeJira: keys = self.search_keys(q.get("jql")) start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) page = keys[start:start + maxr] - return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]} + fields = (q.get("fields") or "").split(",") if q.get("fields") else [] + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), + "issues": [self.issue_json(k, fields) for k in page]} if p == "/rest/api/2/issue" and method == "POST": self.seq += 1 key = "%s-%d" % (self.project, self.seq) @@ -222,7 +240,9 @@ class FakeJira: start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 1000)) return 200, {"startAt": start, "maxResults": maxr, "total": len(lst), "comments": lst[start:start + maxr]} self.comment_seq += 1 - lst.append({"id": str(self.comment_seq), "body": body["body"]}) + ts = self.ts(self.now) + lst.append({"id": str(self.comment_seq), "body": body["body"], "author": self._user("me"), + "created": ts, "updated": ts}) return 201, lst[-1] m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p) if m and method == "PUT": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index d5235fc..9a1cb08 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -78,6 +78,51 @@ class RefreshTest(unittest.TestCase): def test_refresh_empty_keys(self): out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}}) self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0}) + + def test_comments_in_state(self): + self.fake.add("AURORA-1") + self.fake.add("AURORA-2") + self.fake.add_comment("AURORA-1", "первый\nвторая строка", name="petrov", display="Петров Пётр", + created="2026-10-14T10:00:00.000+0300") + self.fake.add_comment("AURORA-1", "второй", name="ivanov", display=None, created="2026-10-14T11:00:00.000+0300", + updated="2026-10-14T12:00:00.000+0300") + self.fake.add_comment("AURORA-1", None, name=None, display=None) # автор удалён, текста нет + out = self.ex.run({"id": 8, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}}) + self.assertEqual(out["status"], "done") + by_key = {i["key"]: i for i in self.hub.states[0]["issues"]} + c = by_key["AURORA-1"]["comments"] + self.assertEqual([x["author"] for x in c], ["Петров Пётр", "ivanov", None]) + self.assertEqual(c[0], {"id": c[0]["id"], "author": "Петров Пётр", "created": "2026-10-14T10:00:00.000+0300", + "updated": "2026-10-14T10:00:00.000+0300", "body": "первый\nвторая строка"}) + self.assertIsInstance(c[0]["id"], str) + self.assertEqual(c[1]["updated"], "2026-10-14T12:00:00.000+0300") + self.assertEqual(c[2]["body"], "") + self.assertEqual(by_key["AURORA-2"]["comments"], []) + self.assertIn("comment", self.ex.state_fields().split(",")) + searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"] + self.assertEqual(len(searches), 1) # комментарии пришли тем же search + self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests)) + + def test_comments_partial_list_is_read_fully(self): + self.fake.add("AURORA-1") + for n in range(130): + self.fake.add_comment("AURORA-1", "к%d" % n) + self.fake.search_comment_cap = 20 + out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + self.assertEqual(out["status"], "done") + c = self.hub.states[0]["issues"][0]["comments"] + self.assertEqual(len(c), 130) + self.assertEqual([x["body"] for x in c[:2]] + [c[-1]["body"]], ["к0", "к1", "к129"]) + reads = [r for r in self.fake.requests if r == ("GET", "/rest/api/2/issue/AURORA-1/comment")] + self.assertEqual(len(reads), 2) # 100 + 30 + + def test_comments_field_missing(self): + raw = {"key": "AURORA-1", "fields": {}} # поле comment недоступно + self.assertEqual(self.ex.comments_of(raw), []) + raw = {"key": "AURORA-1", "fields": {"comment": {"comments": [{"id": "5", "body": "x"}]}}} # total нет + self.assertEqual(self.ex.comments_of(raw), + [{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}]) + self.assertEqual(self.fake.requests, []) # без дочитывания self.assertEqual(self.fake.requests, []) self.assertEqual(self.hub.states, [])