From cd8213b71c7f2cfb6f0d109a048762f7ac0ff2e5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:41:24 +0400 Subject: [PATCH] =?UTF-8?q?fix(agent):=20refresh=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F=D1=85=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20id=20=D0=B8=20=D0=BD=D0=B0=20403/404=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B4=D0=BE=D1=87=D0=B8=D1=82=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B8=20(M-1,=20M-2=20#30,=20#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ src/agent.py | 38 ++++++++++++++++++++++---------- tests/fake_jira.py | 3 +++ tests/test_refresh.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f2502ab..1098d0b 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,8 @@ python3 agent.py prune С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через `/issue/{key}/comment`. + С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии + задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается. 5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, завершающий перевод строки) — изменение. diff --git a/src/agent.py b/src/agent.py index 0b36f3d..df5e457 100644 --- a/src/agent.py +++ b/src/agent.py @@ -900,22 +900,36 @@ 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 ""} - def comments_of(self, raw: dict) -> List[dict]: + def comments_of(self, raw: dict) -> Optional[List[dict]]: """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично - через /issue/{key}/comment и взять полный список вместо частичного.""" + через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30); + 403/404 при дочитывании — None: задача уходит без comments, хаб оставит прежние (M-2 #30).""" + key = raw["key"] 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] + try: + while True: + page = self.jira.get(API + "/issue/%s/comment" % 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 + except HttpError as e: + if e.code not in (403, 404): + raise + log("refresh: %s — комментарии не дочитаны (HTTP %d), задача уходит без comments" % (key, e.code)) + return None + out = [] + for c in got: + if c.get("id") is None or str(c.get("id")) == "": + log("refresh: %s — комментарий без id пропущен" % key) + continue + out.append(self.comment_state(c)) + return out def sprint_names(self, keys: List[str]) -> Dict[str, str]: # Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, @@ -969,7 +983,9 @@ class Executor: for r in everything: st = self.to_state(r) st["sprint"] = names.get(r["key"]) - st["comments"] = self.comments_of(r) + comments = self.comments_of(r) + if comments is not None: # None — нет доступа к комментариям: поле не передаём (M-2 #30) + st["comments"] = comments 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 8f4488e..b3e2813 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.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи) self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.lock = threading.Lock() fake = self @@ -234,6 +235,8 @@ class FakeJira: m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p) if m: key = m.group(1) + if method == "GET" and key in self.comment_denied: + return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]} self.issues[key] lst = self.comments.setdefault(key, []) if method == "GET": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index f4baf9e..33bc830 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -126,6 +126,56 @@ class RefreshTest(unittest.TestCase): [{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}]) self.assertEqual(self.fake.requests, []) # без дочитывания + def test_comment_without_id_dropped(self): + self.fake.add("AURORA-1") + self.fake.add_comment("AURORA-1", "нормальный") + self.fake.add_comment("AURORA-1", "второй")["id"] = None + self.fake.add_comment("AURORA-1", "третий")["id"] = "" + logs = [] + orig = agent.log + agent.log = logs.append + try: + out = self.ex.run({"id": 10, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + finally: + agent.log = orig + self.assertEqual(out["status"], "done") + c = self.hub.states[0]["issues"][0]["comments"] + self.assertEqual([x["body"] for x in c], ["нормальный"]) + self.assertEqual(len([l for l in logs if "AURORA-1" in l and "комментарий без id" in l]), 2, logs) + + def test_comments_read_denied_keeps_refresh(self): + for key in ("AURORA-1", "AURORA-2", "AURORA-3"): + self.fake.add(key) + for n in range(3): + self.fake.add_comment(key, "%s-%d" % (key, n)) + self.fake.search_comment_cap = 1 # search отдаёт 1 из 3 — нужно дочитывание + self.fake.comment_denied = {"AURORA-1": 403, "AURORA-2": 404} + logs = [] + orig = agent.log + agent.log = logs.append + try: + out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-3"]}}) + finally: + agent.log = orig + self.assertEqual(out["status"], "done") + by_key = {i["key"]: i for i in self.hub.states[0]["issues"]} + self.assertNotIn("comments", by_key["AURORA-1"]) + self.assertNotIn("comments", by_key["AURORA-2"]) + self.assertEqual([x["body"] for x in by_key["AURORA-3"]["comments"]], ["AURORA-3-0", "AURORA-3-1", "AURORA-3-2"]) + self.assertTrue(any("AURORA-1" in l and "HTTP 403" in l for l in logs), logs) + self.assertTrue(any("AURORA-2" in l and "HTTP 404" in l for l in logs), logs) + + def test_comments_read_5xx_still_fails_refresh(self): + self.fake.add("AURORA-1") + for n in range(3): + self.fake.add_comment("AURORA-1", "к%d" % n) + self.fake.search_comment_cap = 1 + self.fake.comment_denied = {"AURORA-1": 500} + out = self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) # 5xx — по-прежнему повтор через проверку (R-10) + self.assertEqual(self.hub.states, []) + def test_refresh_empty_jql_result(self): out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) self.assertEqual(out["status"], "done")