fix(agent): ошибка спринтов refresh называет доску, доска-канбан пропускается (I-1, M-2..M-5, #35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
719d4bceef
commit
ced48cf9db
@ -242,7 +242,8 @@ python3 agent.py prune
|
|||||||
3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по
|
3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по
|
||||||
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного
|
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного
|
||||||
типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если
|
типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если
|
||||||
scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом Jira (R-32).
|
scrum-доска стала канбаном, `refresh` пропускает её и сообщает хабу новый тип (R-36); если доска не отдала спринты по
|
||||||
|
другой причине (удалена, нет доступа), `refresh` завершается `failed` с номером доски и текстом Jira (R-32).
|
||||||
4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
|
4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
|
||||||
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
|
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
|
||||||
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).
|
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
schema: spec-driven
|
schema: spec-driven
|
||||||
created: 2026-10-19
|
created: 2026-10-19
|
||||||
|
skip_specs: true
|
||||||
|
|||||||
@ -17,3 +17,4 @@
|
|||||||
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
|
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
|
||||||
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
|
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
|
||||||
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
|
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
|
||||||
|
| R-36 | 2026-10-19 | По находке I-1 финального ревью: если scrum-доска не отдала спринты в `refresh`, агент перепроверяет её тип; стала `kanban` — доска пропускается, в результате `boards: {"<id>": "kanban"}`, `refresh` `done`; иначе `failed` с текстом «доска <id>: …» (код и `retryable` прежние) | Смена доски на канбан лечится без правки базы; при прочих ошибках владелец видит, какую доску править (R-32 расширил зону поражения на все проекты) | Лишний `GET /board/{id}` при ошибке спринтов; типы досок, определённые в упавшем прогоне, не доходят до хаба — следующий `refresh` спросит снова |
|
||||||
|
|||||||
26
src/agent.py
26
src/agent.py
@ -1043,7 +1043,9 @@ class Executor:
|
|||||||
types: Dict[str, Optional[str]] = {}
|
types: Dict[str, Optional[str]] = {}
|
||||||
seen = set()
|
seen = set()
|
||||||
for b in p.get("boards") or []:
|
for b in p.get("boards") or []:
|
||||||
bid = (b or {}).get("id")
|
if not isinstance(b, dict): # хаб шлёт [{id, type}]; иное не роняет refresh (M-2)
|
||||||
|
continue
|
||||||
|
bid = b.get("id")
|
||||||
if bid is None or str(bid) in seen:
|
if bid is None or str(bid) in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(str(bid))
|
seen.add(str(bid))
|
||||||
@ -1054,15 +1056,28 @@ class Executor:
|
|||||||
scrum.append(bid)
|
scrum.append(bid)
|
||||||
return scrum, (types or None)
|
return scrum, (types or None)
|
||||||
|
|
||||||
def sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]:
|
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
|
||||||
|
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
|
||||||
|
(R-36); иначе ошибка Jira с номером доски, refresh failed (R-32, I-1)."""
|
||||||
|
try:
|
||||||
|
return self.sprints(board)
|
||||||
|
except HttpError as e:
|
||||||
|
if self.board_type(board) == "kanban":
|
||||||
|
types[str(board)] = "kanban"
|
||||||
|
return {}
|
||||||
|
raise HttpError(e.code, "доска %s: %s" % (board, e.text), e.headers) from None
|
||||||
|
|
||||||
|
def sprint_names(self, keys: List[str], boards: List[Any],
|
||||||
|
types: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, str]:
|
||||||
# Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет,
|
# Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет,
|
||||||
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок —
|
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок —
|
||||||
# побеждает последнее найденное имя.
|
# побеждает последнее найденное имя.
|
||||||
names = {}
|
names = {}
|
||||||
if not keys:
|
if not keys:
|
||||||
return names
|
return names
|
||||||
|
types = {} if types is None else types
|
||||||
for board in boards:
|
for board in boards:
|
||||||
for name, sp in self.sprints(board).items():
|
for name, sp in self.board_sprints(board, types).items():
|
||||||
for i in range(0, len(keys), 100):
|
for i in range(0, len(keys), 100):
|
||||||
chunk = keys[i:i + 100]
|
chunk = keys[i:i + 100]
|
||||||
start = 0
|
start = 0
|
||||||
@ -1107,7 +1122,8 @@ class Executor:
|
|||||||
for i in range(0, len(absent), 100):
|
for i in range(0, len(absent), 100):
|
||||||
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
||||||
everything = raws + neighbors
|
everything = raws + neighbors
|
||||||
names = self.sprint_names([r["key"] for r in everything], scrum)
|
types = dict(types or {})
|
||||||
|
names = self.sprint_names([r["key"] for r in everything], scrum, types)
|
||||||
issues, links, seen = [], [], set()
|
issues, links, seen = [], [], set()
|
||||||
for r in everything:
|
for r in everything:
|
||||||
st = self.to_state(r)
|
st = self.to_state(r)
|
||||||
@ -1134,7 +1150,7 @@ class Executor:
|
|||||||
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
||||||
if missing:
|
if missing:
|
||||||
result["missing"] = list(dict.fromkeys(missing))[:50]
|
result["missing"] = list(dict.fromkeys(missing))[:50]
|
||||||
if types is not None:
|
if types:
|
||||||
result["boards"] = types
|
result["boards"] = types
|
||||||
return self._out("done", result)
|
return self._out("done", result)
|
||||||
|
|
||||||
|
|||||||
@ -361,6 +361,8 @@ class FakeJira:
|
|||||||
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
|
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
|
||||||
if m:
|
if m:
|
||||||
board = int(m.group(1))
|
board = int(m.group(1))
|
||||||
|
if board not in self.boards:
|
||||||
|
return 404, {"errorMessages": ["Board does not exist"]}
|
||||||
if self.boards.get(board) == "kanban":
|
if self.boards.get(board) == "kanban":
|
||||||
return 400, {"errorMessages": ["The board does not support sprints"]}
|
return 400, {"errorMessages": ["The board does not support sprints"]}
|
||||||
states = (q.get("state") or "active,future").split(",")
|
states = (q.get("state") or "active,future").split(",")
|
||||||
|
|||||||
@ -219,6 +219,7 @@ class ExecutorLinksTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
||||||
|
|
||||||
def test_sprint_unknown_name_names_param_board(self):
|
def test_sprint_unknown_name_names_param_board(self):
|
||||||
|
self.fake.boards[5120] = "scrum"
|
||||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||||
self.assertEqual(out["status"], "failed")
|
self.assertEqual(out["status"], "failed")
|
||||||
|
|||||||
@ -302,7 +302,6 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertTrue(out["retryable"])
|
self.assertTrue(out["retryable"])
|
||||||
self.assertNotIn("peer", out)
|
self.assertNotIn("peer", out)
|
||||||
|
|
||||||
|
|
||||||
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
|
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
|
||||||
|
|
||||||
def refresh(self, **params):
|
def refresh(self, **params):
|
||||||
@ -374,6 +373,7 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertEqual(out["status"], "done", out)
|
self.assertEqual(out["status"], "done", out)
|
||||||
self.assertEqual(out["result"]["boards"], {"5120": None})
|
self.assertEqual(out["result"]["boards"], {"5120": None})
|
||||||
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
|
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
|
||||||
|
self.assertEqual(len(self.hub.states), 1) # M-4: состояние ушло в хаб
|
||||||
|
|
||||||
def test_board_other_type_is_null(self):
|
def test_board_other_type_is_null(self):
|
||||||
self.fake.boards[7000] = "simple"
|
self.fake.boards[7000] = "simple"
|
||||||
@ -404,14 +404,29 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.refresh(jql=self.JQL, boards=both[::-1])
|
self.refresh(jql=self.JQL, boards=both[::-1])
|
||||||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
|
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
|
||||||
|
|
||||||
def test_scrum_board_sprints_error_fails_refresh(self): # R-32
|
def test_scrum_board_turned_kanban_self_heals(self): # I-1, R-36
|
||||||
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
|
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
|
||||||
|
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||||
|
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}])
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||||
|
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||||
|
|
||||||
|
def test_scrum_board_sprints_error_fails_refresh(self): # R-32, I-1: ошибка называет доску
|
||||||
self.fake.add("AURORA-1")
|
self.fake.add("AURORA-1")
|
||||||
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}])
|
out = self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": "scrum"}]) # доски нет в Jira
|
||||||
self.assertEqual(out["status"], "failed")
|
self.assertEqual(out["status"], "failed")
|
||||||
self.assertIn("HTTP 400", out["error"])
|
self.assertIn("HTTP 404", out["error"])
|
||||||
|
self.assertIn("доска 9999", out["error"])
|
||||||
|
self.assertFalse(out["retryable"])
|
||||||
self.assertEqual(self.hub.states, [])
|
self.assertEqual(self.hub.states, [])
|
||||||
|
|
||||||
|
def test_non_dict_board_item_skipped(self): # M-2
|
||||||
|
self.fake.boards[5120] = "kanban"
|
||||||
|
out = self.refresh(keys=[], boards=[4216, None, {"id": 5120, "type": None}])
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user