fix(agent): refresh не падает на комментариях без id и на 403/404 при дочитывании (M-1, M-2 #30, #31)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:41:24 +04:00
parent da5d0b1d6d
commit cd8213b71c
4 changed files with 82 additions and 11 deletions

View File

@ -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`); отправляется текст команды как есть. Прочие различия (пробелы,
завершающий перевод строки) — изменение.

View File

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

View File

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

View File

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