Сравнивать «готово» при закрытии спринта по id статусов, а не по именам (#49)

C-1 финального ревью: ensure_sprint_state closed определял готовые задачи по именам
через молча неполный кэш /status и мог необратимо перенести выполненные задачи.
Теперь id статусов последней колонки берутся из configuration, у задач — status.id;
пустой набор id — failed без записи, задача без status.id считается незавершённой.
В board_columns неизвестный id один раз за команду перечитывает /status, затем пишет
в лог; колонка без строкового имени пропускается с логом. Решение R-83.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 19:42:58 +04:00
parent 3693578648
commit 9d3958e337
6 changed files with 102 additions and 23 deletions

View File

@ -174,7 +174,7 @@ python3 agent.py prune
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
| `ensure_sprint_meta` | (v15) имя, даты и цель спринта доски `board_id` команды; спринта нет — создаёт, есть — правит только отличающиеся поля; `rename_to` переименовывает (повтор после переименования безопасен); даты — `ГГГГ-ММ-ДД`, в Jira — 09:00 и 18:00 по локальному времени агента; `goal: ""` очищает цель, отсутствующий `goal` цель не трогает; закрытый спринт не правится — `failed` |
| `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; все проверки — до первой записи; уже в нужном состоянии — `done` |
| `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; «выполнено» — по id статусов последней колонки доски (не по именам); все проверки — до первой записи; уже в нужном состоянии — `done` |
| `ensure_rank` | (v15) ставит задачу `key` выше (`before`) или ниже (`after`) другой задачи, ровно одно из двух; поле ранга ищется по `GET /field`; задача уже на месте — ничего не пишет; `entries` с неуспешным статусом в ответе Jira — `failed` |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `<base_url>/secure/attachment/<id>/<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется |

View File

@ -78,3 +78,4 @@
| R-80 | 2026-10-24 | `ensure_sprint_meta`: `goal` не передан — цель не трогается (очистка — `goal: ""`); `rename_to` равный `sprint` считается отсутствующим; «имя занято» и повтор после переименования учитывают только не закрытые спринты с именем `rename_to` (дубли `sprint` — спринты любых состояний); порядок проверок: даты → доска → дубли → `rename_to` → «спринт закрыт» → запись | Решения исполнителя Task 5; нужны идемпотентность повтора (R-69) и отсутствие записи до проверок | Хаб ждёт очистки цели через `null` — цель не очистится, передавать `""` |
| R-81 | 2026-10-24 | `ensure_sprint_state`: задача без статуса в ответе — незавершённая; дубликаты имени цели переноса — ошибка «несколько спринтов с именем…»; зазор `gap` ставится и перед закрытием спринта, если до этого были переносы | Решения исполнителя Task 6; безопаснее перенести лишнее, чем потерять незавершённое | Задача без статуса, на деле готовая, уедет в следующий спринт |
| R-82 | 2026-10-24 | `ensure_rank`: элемент `entries` с неуспешным статусом без `errors` даёт текст «статус <N>» | Текст для этого случая планом не задан; пустой хвост после двоеточия бесполезен | Нет |
| R-83 | 2026-10-24 | `ensure_sprint_state`: «готово» определяется по id статусов последней колонки (из configuration) и `status.id` задач, а не по именам; пустой набор id — `failed`; в `refresh` неизвестный id статуса → один раз перечитать `/status`, затем лог | Необратимый перенос готовых задач при неполном/устаревшем кэше имён (C-1 финального ревью) | Ещё один `GET /status` при неизвестном id; отступление от п. 7 design по способу (имена → id), не по смыслу |

View File

@ -706,6 +706,7 @@ class Executor:
self._sprints = {} # str(доска) → имя → спринт; на одну команду
self._all_sprints = {} # str(доска) → все спринты доски (любых состояний); на одну команду
self._status_names = None # id статуса → имя; кэш на экземпляр, пока жив Executor
self._status_reread = False # /status уже перечитан в этой команде (неизвестный id статуса, C-1)
self._field_list = None # GET /field: список полей на одну команду
self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields
self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor
@ -714,6 +715,7 @@ class Executor:
self.jira.reset_counters()
self._sprints = {}
self._all_sprints = {}
self._status_reread = False
self._field_list = None
if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67)
self._jira_fields = None
@ -1145,22 +1147,22 @@ class Executor:
# --- ensure_sprint_state ---------------------------------------------------
def sprint_issues(self, sprint_id: Any) -> List[Tuple[str, Optional[str]]]:
"""Все задачи спринта: (ключ, имя статуса), постранично по 50."""
"""Все задачи спринта: (ключ, id статуса строкой или None, если Jira его не отдала), постранично по 50."""
out: List[Tuple[str, Optional[str]]] = []
start = 0
while True:
page = self.jira.get(AGILE + "/sprint/%s/issue" % sprint_id, fields="status", startAt=start, maxResults=50)
items = page.get("issues") or []
for it in items:
status = (((it.get("fields") or {}).get("status")) or {}).get("name")
out.append((it["key"], status))
sid = (((it.get("fields") or {}).get("status")) or {}).get("id")
out.append((it["key"], str(sid) if sid is not None else None))
start += len(items)
if not items or start >= int(page.get("total") or 0):
return out
def do_ensure_sprint_state(self, p: dict, cmd: dict) -> dict:
"""Запуск (active) или завершение (closed) спринта. Все проверки — до первой записи (R-71); при завершении
незавершённые задачи (статус не в последней колонке доски) переносятся в `move_open_to` (backlog или другой
незавершённые задачи (id статуса не среди id статусов последней колонки доски, R-83) переносятся в `move_open_to` (backlog или другой
активный/будущий спринт той же доски), затем спринт закрывается. Спринт уже в нужном состоянии — ничего не пишем."""
state = p.get("state")
if state not in ("active", "closed"):
@ -1198,11 +1200,13 @@ class Executor:
if not cands:
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s" % (to, board))
target = cands[0]
cols = self.board_columns(board)
if not cols:
cols = self.board_config_columns(board)
done_ids = set()
if cols:
done_ids = {str(st["id"]) for st in cols[-1].get("statuses") or [] if isinstance(st, dict) and st.get("id") is not None}
if not done_ids: # колонок нет, ошибка чтения или пустая последняя колонка — не гадаем (C-1)
return self._out("failed", error="не удалось прочитать колонки доски")
done_statuses = set(cols[-1]["statuses"])
moved = [k for k, st in self.sprint_issues(sp["id"]) if st not in done_statuses]
moved = [k for k, sid in self.sprint_issues(sp["id"]) if sid is None or sid not in done_ids]
first = True
for i in range(0, len(moved), 50):
if not first:
@ -1554,18 +1558,45 @@ class Executor:
self._status_names = {str(x["id"]): x["name"] for x in self.jira.get(API + "/status") if x.get("id") and x.get("name")}
return self._status_names
def board_columns(self, board: Any) -> Optional[List[dict]]:
"""Колонки доски: [{name, statuses: [имя]}]; неизвестный id статуса пропускается; ошибка Jira — лог и None."""
def board_config_columns(self, board: Any) -> Optional[List[dict]]:
"""Колонки доски как их отдаёт Jira (GET /board/{id}/configuration): [{name, statuses: [{id}]}];
ошибка Jira — лог и None."""
try:
names = self.status_names()
cfg = self.jira.get(AGILE + "/board/%s/configuration" % board)
except HttpError as e:
log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code))
return None
out = []
for col in (cfg.get("columnConfig") or {}).get("columns") or []:
out.append({"name": col.get("name"),
"statuses": [names[str(st.get("id"))] for st in col.get("statuses") or [] if str(st.get("id")) in names]})
return (cfg.get("columnConfig") or {}).get("columns") or []
def board_columns(self, board: Any) -> Optional[List[dict]]:
"""Колонки доски для хаба: [{name, statuses: [имя]}]. Неизвестный id статуса: один раз за команду кэш имён
перечитывается (статус могли добавить после заполнения кэша), всё ещё неизвестен — пропуск с записью в лог (C-1);
колонка без строкового имени — пропуск с логом (M-3); ошибка Jira — лог и None."""
raw = self.board_config_columns(board)
if raw is None:
return None
try:
names = self.status_names()
out = []
for col in raw:
cname = col.get("name")
if not isinstance(cname, str):
log("доска %s: колонка %r пропущена — нет строкового имени" % (board, cname))
continue
statuses = []
for st in col.get("statuses") or []:
sid = str(st.get("id"))
if sid not in names and not self._status_reread:
self._status_reread, self._status_names = True, None
names = self.status_names()
if sid in names:
statuses.append(names[sid])
else:
log("доска %s: статус %s колонки «%s» не найден" % (board, sid, cname))
out.append({"name": cname, "statuses": statuses})
except HttpError as e:
log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code))
return None
return out
def kanban_backlog(self, board: Any) -> Optional[bool]:

View File

@ -78,6 +78,7 @@ class FakeJira:
self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id}
self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
self.board_columns = {} # доска → список как COLUMNS (нет доски — COLUMNS)
self.statuses = dict(STATUSES) # id → имя статуса (GET /status); тесты могут добавить статус
self.backlog_codes = {} # доска → код ответа GET /board/{id}/backlog (по умолчанию 200)
self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им
self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper
@ -486,7 +487,7 @@ class FakeJira:
return 404, {"errorMessages": ["Board does not exist"]}
return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]}
if p == "/rest/api/2/status":
return 200, [{"id": i, "name": n} for i, n in STATUSES.items()]
return 200, [{"id": i, "name": n} for i, n in self.statuses.items()]
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/configuration$", p)
if m:
board = int(m.group(1))
@ -543,7 +544,7 @@ class FakeJira:
if (k in members if members is not None else self.issues[k]["sprint"] == sp["name"])]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
"issues": [{"key": k, "fields": {"status": {"name": self.issues[k]["status"]}}}
"issues": [{"key": k, "fields": {"status": self.status_json(self.issues[k]["status"])}}
for k in keys[start:start + maxr]]}
for k in body["issues"]:
self.issues[k]["sprint"] = sp["name"]
@ -586,6 +587,11 @@ class FakeJira:
return 207, {"entries": entries}
return 204, None
def status_json(self, name):
"""{id, name} статуса по имени; имени нет в self.statuses — статус без id (как неполный ответ Jira)."""
ids = [i for i, n in self.statuses.items() if n == name]
return {"id": ids[0], "name": name} if ids else {"name": name}
def all_sprints(self):
out = list(self.sprints)
for vals in self.board_sprints.values():

View File

@ -179,6 +179,29 @@ class ExecutorSprintsTest(unittest.TestCase):
self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски"))
self.assertEqual(self.fake.writes(), [])
def test_close_last_column_unknown_status_no_writes(self): # C-1: пустой набор id последней колонки
self.sprint_with(2, 1)
self.fake.board_columns[4216] = [("В работе", ["10003"]), ("Готово", [])]
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски"))
self.assertEqual(self.fake.writes(), [])
def test_close_status_added_after_cache(self): # C-1: статус добавлен в последнюю колонку после заполнения кэша
open_keys = self.sprint_with(0, 1)
self.ex.status_names() # кэш имён заполнен до появления нового статуса
self.fake.statuses["10010"] = "Принято"
self.fake.board_columns[4216] = [("В работе", ["10003"]), ("Готово", ["10006", "10010"])]
self.fake.add("AURORA-3000", sprint="AWPCA_Sprint_3_Corrections", status="Принято")
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys})
self.assertEqual(self.fake.issues["AURORA-3000"]["sprint"], "AWPCA_Sprint_3_Corrections")
def test_close_issue_without_status_id_is_open(self): # R-81: нет status.id — незавершённая
self.sprint_with(1, 0)
self.fake.add("AURORA-3001", sprint="AWPCA_Sprint_3_Corrections", status="Без id в statuses")
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
self.assertEqual(out["result"], {"sprint_id": 3, "moved": ["AURORA-3001"]})
# --- ensure_rank ---
def test_rank_already(self): # сценарий «Ранг уже такой»
self.fake.add("AURORA-1612", customfield_10105="0|i0001:")

View File

@ -589,12 +589,30 @@ class RefreshTest(unittest.TestCase):
self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")])
self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs)
def test_unknown_status_id_skipped_and_status_cached(self):
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])]
def test_unknown_status_id_skipped_and_status_cached(self): # C-1: один перечитывающий GET /status за команду + лог
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"]), ("Две", ["99998"])]
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(self.hub.states[0]["boards"][0]["columns"],
[{"name": "Одна", "statuses": ["Разработка"]}, {"name": "Две", "statuses": []}])
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 2)
self.assertTrue(any("4216" in l and "99999" in l and "«Одна»" in l and "не найден" in l for l in logs), logs)
self.assertTrue(any("99998" in l and "«Две»" in l for l in logs), logs)
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}])
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1)
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 3)
def test_unknown_status_found_after_reread(self): # статус добавлен в Jira после заполнения кэша — не теряется
self.ex.status_names()
self.fake.statuses["10010"] = "Принято"
self.fake.board_columns[4216] = [("Одна", ["10003", "10010"])]
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка", "Принято"]}])
self.assertEqual([l for l in logs if "не найден" in l], [])
def test_column_without_string_name_skipped(self): # M-3
self.fake.board_columns[4216] = [(None, ["10003"]), ("Ок", ["10003"])]
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Ок", "statuses": ["Разработка"]}])
self.assertTrue(any("4216" in l and "колонка" in l and "пропущена" in l for l in logs), logs)
def test_empty_jql_with_boards_sends_empty_batch(self): # R-66
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])