diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e18661c..96b6fa4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ Jira и хаба. Спека: `../sdlc-hub/openspec/changes/bridge-core/`, пл | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `sdlc-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | -| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной; v15: поля ранга, спринта и оценки — `jira_fields` по `field_list` (`GET /field` один раз на команду; перекрытие `field_ids`), доски — `board_state` (спринты через `all_sprints`), новые `do_ensure_sprint_meta`, `do_ensure_sprint_state`, `do_ensure_rank`, `story_points` в `do_ensure_fields` | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной; v15: поля ранга, спринта и оценки — `jira_fields` по `field_list` (`GET /field` один раз на команду; перекрытие `field_ids`), доски — `board_state` (спринты через `all_sprints`), новые `do_ensure_sprint_meta`, `do_ensure_sprint_state`, `do_ensure_rank`, `story_points` в `do_ensure_fields`; v16: оценка с `board_id` — через `/rest/agile/1.0/issue/{key}/estimation` доски, остальные поля — `ensure_issue_fields` | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | | Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` | diff --git a/CLAUDE.md b/CLAUDE.md index 3995001..69e4638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`. -Последний change — `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг, оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — `docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md`. +Последний change — `../sdlc-hub/openspec/changes/sp-estimation-board/` (агент v16: оценка через доску; решения агента — `openspec/changes/agent-v16-sp-estimation/design.md`), план — `docs/superpowers/plans/2026-10-25-sp-estimation-agent.md`. План и спека живут в `sdlc-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. ## Выкатка новой версии diff --git a/README.md b/README.md index cc497b4..6c8ee85 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ python3 agent.py prune | Тип | Что делает | |---|---| | `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` | -| `ensure_fields` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка) | +| `ensure_fields` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка); с v16 при `board_id` в команде (хаб ставит доску проекта) оценка читается и пишется через `/rest/agile/1.0/issue/{key}/estimation?boardId=…` — как в бэклоге Scrum, без экрана редактирования; `GET /issue` — только если есть другие поля | | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | @@ -215,6 +215,8 @@ python3 agent.py prune Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points` в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field` (ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`. + Версия 16 — оценка из `ensure_fields` с `board_id` через `/rest/agile/1.0/issue/{key}/estimation` доски: в Jira банка поле + оценки не на экране редактирования, `PUT /issue` его отклоняет (#53). Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает «неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки. Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды diff --git a/src/agent.py b/src/agent.py index f79b6b5..919c6a4 100644 --- a/src/agent.py +++ b/src/agent.py @@ -33,7 +33,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 15 +AGENT_VERSION = 16 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new HERE = os.path.dirname(os.path.abspath(__file__)) @@ -874,14 +874,30 @@ class Executor: def do_ensure_fields(self, p: dict, cmd: dict) -> dict: key = p["key"] sp_fid = None + # С доской оценка — через estimation доски (v16): поля может не быть на экране редактирования (#53) + by_board = "story_points" in p and bool(p.get("board_id")) if "story_points" in p: + want_sp = p["story_points"] + if want_sp is not None and (isinstance(want_sp, bool) or not isinstance(want_sp, (int, float)) or not want_sp >= 0): + return self._out("failed", error="story_points — число ≥ 0 или null") + if "story_points" in p and not by_board: # Настроенный id берётся без GET /field (как в refresh он не нужен); иначе — поиск в jira_fields. sp_fid = (self.s.get("field_ids") or {}).get("story_points") or self.jira_fields()["story_points"] if not sp_fid: raise ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points") - want_sp = p["story_points"] - if want_sp is not None and (isinstance(want_sp, bool) or not isinstance(want_sp, (int, float)) or not want_sp >= 0): - return self._out("failed", error="story_points — число ≥ 0 или null") + changed: List[str] = [] + if set(p) - {"key", "board_id", "story_points"} or not by_board: + changed = self.ensure_issue_fields(key, p, sp_fid) + if by_board: + path = AGILE + "/issue/%s/estimation" % key + if not _same_points(self.jira.get(path, boardId=p["board_id"]).get("value"), p["story_points"]): + self.jira.call("PUT", path, {"value": p["story_points"]}, params={"boardId": p["board_id"]}) + changed.append("story_points") + return self._out("done", {"changed": changed}) + + def ensure_issue_fields(self, key: str, p: dict, sp_fid: Optional[str]) -> List[str]: + """Поля ensure_fields через GET/PUT /issue: только отличающиеся; оценка — здесь лишь при sp_fid (путь v15). + Возвращает имена записанных полей.""" cur = self.jira.get(API + "/issue/%s" % key)["fields"] fields, changed = {}, [] if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): @@ -923,10 +939,9 @@ class Executor: if sp_fid and not _same_points(cur.get(sp_fid), p["story_points"]): fields[sp_fid] = p["story_points"] changed.append("story_points") - if not fields: - return self._out("done", {"changed": []}) - self.jira.put(API + "/issue/%s" % key, {"fields": fields}) - return self._out("done", {"changed": changed}) + if fields: + self.jira.put(API + "/issue/%s" % key, {"fields": fields}) + return changed # --- ensure_comment -------------------------------------------------------- def do_ensure_comment(self, p: dict, cmd: dict) -> dict: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index ff27520..c737575 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -86,6 +86,7 @@ class FakeJira: self.sprint_seq = 500 # id следующего созданного спринта self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank + self.estimation_bodies = [] # тела PUT /rest/agile/1.0/issue/{key}/estimation self.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 @@ -557,6 +558,17 @@ class FakeJira: return 204, None if p == "/rest/agile/1.0/issue/rank" and method == "PUT": return self._rank(body) + m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)/estimation$", p) + if m: # оценка задачи на доске boardId (поле оценки доски — POINTS_FIELD) + if m.group(1) not in self.issues: + return 404, {"errorMessages": ["Issue Does Not Exist"]} + if int(q.get("boardId") or 0) not in self.boards: + return 400, {"errorMessages": ["The board %s does not exist" % q.get("boardId")]} + issue = self.issues[m.group(1)] + if method == "PUT": + self.estimation_bodies.append(body) + issue[POINTS_FIELD] = body.get("value") + return 200, {"fieldId": POINTS_FIELD, "value": issue.get(POINTS_FIELD)} m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) if m: name = self.issues[m.group(1)]["sprint"] diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py index 7b4846a..20bdf5b 100644 --- a/tests/test_executor_fields.py +++ b/tests/test_executor_fields.py @@ -294,5 +294,63 @@ class ExecutorFieldsTest(unittest.TestCase): self.assertEqual(self.fake.writes(), []) + # --- story_points через доску (#53) ------------------------------------------ + def points(self, key, value): + self.fake.add(key, **({} if value is None else {"customfield_10106": value})) + + def test_points_by_board_set(self): # сценарий «Оценка не на экране редактирования» + self.points("AURORA-1704", 1.0) + out = self.run_cmd("ensure_fields", key="AURORA-1704", story_points=2, board_id=4216) + self.assertEqual((out["status"], out["result"], out["jira_calls"]), ("done", {"changed": ["story_points"]}, 2)) + self.assertEqual(self.fake.estimation_bodies, [{"value": 2}]) + self.assertEqual(self.fake.issues["AURORA-1704"]["customfield_10106"], 2) + self.assertEqual(self.fake.queries[-1], {"boardId": "4216"}) + for call in (("GET", "/rest/api/2/issue/AURORA-1704"), ("PUT", "/rest/api/2/issue/AURORA-1704"), + ("GET", "/rest/api/2/field")): + self.assertNotIn(call, self.fake.requests) + + def test_points_by_board_same(self): # сценарий «Оценка уже стоит» + self.points("AURORA-1704", 2.0) + out = self.run_cmd("ensure_fields", key="AURORA-1704", story_points=2, board_id=4216) + self.assertEqual((out["result"], out["jira_calls"]), ({"changed": []}, 1)) + self.assertEqual(self.fake.estimation_bodies, []) + + def test_points_by_board_clear(self): + self.points("AURORA-1", 1.0) + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=None, board_id=4216) + self.assertEqual(out["result"], {"changed": ["story_points"]}) + self.assertEqual(self.fake.estimation_bodies, [{"value": None}]) + again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=None, board_id=4216) + self.assertEqual((again["result"], again["jira_calls"]), ({"changed": []}, 1)) + + def test_points_by_board_with_fields(self): + self.points("AURORA-1", None) + out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=4216) + self.assertEqual(out["result"], {"changed": ["duedate", "story_points"]}) + writes = [r for r in self.fake.requests if r[0] == "PUT"] + self.assertEqual(writes, [("PUT", "/rest/api/2/issue/AURORA-1"), ("PUT", "/rest/agile/1.0/issue/AURORA-1/estimation")]) + self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-30") + self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3) + + def test_points_by_board_jira_error(self): + self.points("AURORA-1", None) + out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=9999) + self.assertEqual(out["status"], "failed") + self.assertIn("9999", out["error"]) + self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-30") + + def test_points_by_board_no_field_lookup(self): + self.points("AURORA-1", None) + self.fake.fields = [] + self.ex.s["field_ids"] = {k: v for k, v in SETTINGS["field_ids"].items() if k != "story_points"} + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3, board_id=4216) + self.assertEqual((out["status"], out["result"]), ("done", {"changed": ["story_points"]})) + + def test_points_by_board_bad_value(self): + self.points("AURORA-1", None) + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points="3", board_id=4216) + self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null")) + self.assertEqual(self.fake.requests, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_folder.py b/tests/test_folder.py index 80c3454..a0eba87 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -197,7 +197,7 @@ class FolderIntegrationTest(unittest.TestCase): return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 15) + self.assertEqual(agent.AGENT_VERSION, 16) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") diff --git a/tests/test_history.py b/tests/test_history.py index 7ff5521..fe0a2b0 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -310,7 +310,7 @@ class RefreshHistoryTest(Base): self.assertEqual(self.changelog_queries(), []) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 15) + self.assertEqual(agent.AGENT_VERSION, 16) if __name__ == "__main__": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index f090357..5dd378c 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -263,8 +263,8 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["status"], "done") self.assertEqual(out["result"]["missing"], ["GONE-1"]) - def test_agent_version_15(self): - self.assertEqual(agent.AGENT_VERSION, 15) + def test_agent_version_16(self): + self.assertEqual(agent.AGENT_VERSION, 16) self.fake.add("AURORA-1") self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) self.assertEqual(self.hub.states[0]["agent_version"], 15)