diff --git a/README.md b/README.md index 3ba0536..cc497b4 100644 --- a/README.md +++ b/README.md @@ -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`), качает содержимое с `/secure/attachment//<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется | diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 22fa3aa..e6f86f8 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -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` даёт текст «статус » | Текст для этого случая планом не задан; пустой хвост после двоеточия бесполезен | Нет | +| 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), не по смыслу | diff --git a/src/agent.py b/src/agent.py index 27b9849..58412d3 100644 --- a/src/agent.py +++ b/src/agent.py @@ -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]: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index fd1eae7..ff27520 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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(): diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index 375a650..38c8f0d 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -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:") diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 2fee472..b006d13 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -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"}])