diff --git a/docs/superpowers/plans/2026-10-15-agent-v10-comments.md b/docs/superpowers/plans/2026-10-15-agent-v10-comments.md new file mode 100644 index 0000000..1f74e07 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-agent-v10-comments.md @@ -0,0 +1,343 @@ +# agent-v10-comments — агент v10: комментарии задач в состоянии — план + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `refresh` агента передаёт хабу комментарии каждой задачи (`comments: [{id, author, created, updated, body}]`), +чтобы карточка задачи веб-приложения хаба (aep/jira-hub#37) могла их показать; выпуск — агент v10. + +**Architecture:** `src/agent.py` — поле `comment` в `Executor.STATE_FIELDS` (тот же `search`, без новых вызовов Jira); +`Executor.comment_state` (форма одного комментария) и `Executor.comments_of` (комментарии из ответа `search`, при +неполном списке — дочитывание `GET /issue/{key}/comment` по 100); `do_refresh` кладёт `comments` в состояние каждой +задачи. Фейк `tests/fake_jira.py` отдаёт `fields.comment` как Jira Server (с `total`) и умеет обрезать список в поиске. + +**Tech Stack:** Python 3.9.6, только стандартная библиотека; `unittest`. + +**Spec:** `../jira-hub/openspec/changes/issue-card-modal/` — `specs/agent/spec.md` (MODIFIED «Чтение состояния»), +`design.md` решения 1–4 и R-1 (worktree спеки: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/issue-modal`). +Решения агентской части — `openspec/changes/agent-v10-comments/design.md` (создаётся в Task 2). + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v10`, ветка `feat/27-agent-v10-comments`. +Все команды — из корня worktree. + +## Global Constraints + +- Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+ запрещён (`tests/test_py39.py`). +- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор. +- `AGENT_VERSION = 10`. +- Комментарий в состоянии: `id` — строка; `author` — `displayName`, иначе `name`, иначе `None`; `created`, `updated` — + строки Jira как есть; `body` — текст как есть, пустой — `""`. Порядок — как отдала Jira. +- Дочитывание: `GET /issue/{key}/comment`, `startAt`, `maxResults = 100`, только если в ответе `search` + `len(comment.comments) < comment.total`. +- `comments` есть у каждой задачи состояния, в том числе у соседей; нет комментариев — `[]`. +- Коммиты на русском, трейлер `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Ответ `search` без `fields.comment` вовсе (поле недоступно по правам) — `comments = []`, `refresh` не падает (Task 1, тест `test_comments_field_missing`). +2. `comment.total` отсутствует или `null` — считать полным полученный список, без дочитывания (Task 1, тот же тест). +3. Автор комментария удалён из Jira (`author` = `null`) — `author = None`, текст на месте (Task 1, `test_comments_in_state`). +4. Комментарий с пустым или отсутствующим `body` — `""`, не `None` (Task 1, `test_comments_in_state`). +5. Дочитывание вернуло 429 — повтор по общему правилу `JiraClient`, как у любого вызова (покрыто `test_clients.py`; отдельного теста не нужно, вызов идёт через `self.jira.get`). + +--- + +### Task 1: Комментарии в состоянии `refresh` + +**Модель исполнителя:** haiku (готовый код). + +**Files:** +- Modify: `tests/fake_jira.py` (комментарии с автором и временем, `fields.comment` в `issue_json`, обрезка в поиске) +- Modify: `src/agent.py:829-830` (`STATE_FIELDS`), раздел `# --- refresh` (`comment_state`, `comments_of`), `do_refresh` +- Test: `tests/test_refresh.py` + +**Interfaces:** +- Produces: `Executor.comment_state(c: dict) -> dict`, `Executor.comments_of(raw: dict) -> List[dict]`; у каждой задачи + в `POST /agent/state` поле `comments`. +- Фейк: `FakeJira.add_comment(key, body, name="ivanov", display="Иванов Иван", created=..., updated=None) -> dict`, + `FakeJira.search_comment_cap: Optional[int]`. + +- [ ] **Step 1: Фейк Jira — комментарии как в Jira Server** + +В `tests/fake_jira.py`, в `__init__` после `self.drop_next_post_issue = False` добавить: + +```python + self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira +``` + +Добавить метод после `add`: + +```python + def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300", + updated=None): + author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None + self.comment_seq += 1 + c = {"id": str(self.comment_seq), "body": body, "author": author, "created": created, "updated": updated or created} + self.comments.setdefault(key, []).append(c) + return c +``` + +Заменить `issue_json` целиком: + +```python + def issue_json(self, key, fields=None): + """fields — список запрошенных полей (как параметр fields у search); None — все поля, как GET /issue/{key}.""" + f = self.issues[key] + out = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, + "priority": {"name": f["priority"], "id": int(f["priority"]) if str(f["priority"]).isdigit() else f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), + "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, + "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} + out.update({c: f.get(c) for c in CUSTOM}) + if fields is None or "comment" in fields: + lst = self.comments.get(key, []) + cap = self.search_comment_cap if fields is not None else None + shown = lst if cap is None else lst[:cap] + out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), + "comments": shown} + return {"key": key, "id": f["id"], "fields": out} +``` + +В `route`, ветка `/rest/api/2/search`, заменить строку возврата: + +```python + fields = (q.get("fields") or "").split(",") if q.get("fields") else [] + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), + "issues": [self.issue_json(k, fields) for k in page]} +``` + +В ветке `POST .../comment` (создание комментария) заменить две строки + +```python + self.comment_seq += 1 + lst.append({"id": str(self.comment_seq), "body": body["body"]}) +``` + +на + +```python + self.comment_seq += 1 + ts = self.ts(self.now) + lst.append({"id": str(self.comment_seq), "body": body["body"], "author": self._user("me"), + "created": ts, "updated": ts}) +``` + +- [ ] **Step 2: Падающие тесты** + +В `tests/test_refresh.py`, в класс `RefreshTest`, после `test_refresh_empty_keys` добавить: + +```python + def test_comments_in_state(self): + self.fake.add("AURORA-1") + self.fake.add("AURORA-2") + self.fake.add_comment("AURORA-1", "первый\nвторая строка", name="petrov", display="Петров Пётр", + created="2026-10-14T10:00:00.000+0300") + self.fake.add_comment("AURORA-1", "второй", name="ivanov", display=None, created="2026-10-14T11:00:00.000+0300", + updated="2026-10-14T12:00:00.000+0300") + self.fake.add_comment("AURORA-1", None, name=None, display=None) # автор удалён, текста нет + out = self.ex.run({"id": 8, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}}) + self.assertEqual(out["status"], "done") + by_key = {i["key"]: i for i in self.hub.states[0]["issues"]} + c = by_key["AURORA-1"]["comments"] + self.assertEqual([x["author"] for x in c], ["Петров Пётр", "ivanov", None]) + self.assertEqual(c[0], {"id": c[0]["id"], "author": "Петров Пётр", "created": "2026-10-14T10:00:00.000+0300", + "updated": "2026-10-14T10:00:00.000+0300", "body": "первый\nвторая строка"}) + self.assertIsInstance(c[0]["id"], str) + self.assertEqual(c[1]["updated"], "2026-10-14T12:00:00.000+0300") + self.assertEqual(c[2]["body"], "") + self.assertEqual(by_key["AURORA-2"]["comments"], []) + self.assertIn("comment", self.ex.state_fields().split(",")) + searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"] + self.assertEqual(len(searches), 1) # комментарии пришли тем же search + self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests)) + + def test_comments_partial_list_is_read_fully(self): + self.fake.add("AURORA-1") + for n in range(130): + self.fake.add_comment("AURORA-1", "к%d" % n) + self.fake.search_comment_cap = 20 + out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + self.assertEqual(out["status"], "done") + c = self.hub.states[0]["issues"][0]["comments"] + self.assertEqual(len(c), 130) + self.assertEqual([x["body"] for x in c[:2]] + [c[-1]["body"]], ["к0", "к1", "к129"]) + reads = [r for r in self.fake.requests if r == ("GET", "/rest/api/2/issue/AURORA-1/comment")] + self.assertEqual(len(reads), 2) # 100 + 30 + + def test_comments_field_missing(self): + raw = {"key": "AURORA-1", "fields": {}} # поле comment недоступно + self.assertEqual(self.ex.comments_of(raw), []) + raw = {"key": "AURORA-1", "fields": {"comment": {"comments": [{"id": "5", "body": "x"}]}}} # total нет + self.assertEqual(self.ex.comments_of(raw), + [{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}]) + self.assertEqual(self.fake.requests, []) # без дочитывания +``` + +- [ ] **Step 3: Тесты падают** + +Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` +Expected: FAIL — `KeyError: 'comments'` в `test_comments_in_state` и `test_comments_partial_list_is_read_fully`, +`AttributeError: 'Executor' object has no attribute 'comments_of'` в `test_comments_field_missing`. + +- [ ] **Step 4: Реализация в агенте** + +В `src/agent.py` заменить `STATE_FIELDS`: + +```python + STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", + "issuetype", "created", "updated", "issuelinks", "comment"] +``` + +После метода `links_from` добавить: + +```python + @staticmethod + def comment_state(c: dict) -> dict: + """Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira.""" + a = c.get("author") or {} + 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]: + """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично + через /issue/{key}/comment и взять полный список вместо частичного.""" + 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] +``` + +В `do_refresh`, в цикле `for r in everything:` после строки `st["sprint"] = names.get(r["key"])` добавить: + +```python + st["comments"] = self.comments_of(r) +``` + +- [ ] **Step 5: Тесты проходят, весь набор зелёный** + +Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` +Expected: PASS (все тесты `RefreshTest`, включая три новых). + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +Expected: `OK` (ни одного FAIL/ERROR; `test_executor_fields` с `ensure_comment` — тоже зелёный: форма созданного фейком +комментария дополнена, но `body` и `id` прежние). + +- [ ] **Step 6: Commit** + +```bash +git add src/agent.py tests/fake_jira.py tests/test_refresh.py +git commit -m "feat(agent): комментарии задач в состоянии refresh (#27) + +Поле comment в том же search; при неполном списке — дочитывание /issue/{key}/comment по 100. +Спека: jira-hub/openspec/changes/issue-card-modal/ (agent, «Чтение состояния»). + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 2: Версия 10, документация, change решений агента + +**Модель исполнителя:** haiku (готовый текст). + +**Files:** +- Modify: `src/agent.py:36` (`AGENT_VERSION`) +- Modify: `tests/test_folder.py:161` +- Modify: `README.md` (раздел особенностей `refresh`, пункт 4) +- Modify: `CLAUDE.md` (раздел «Первая подсистема» — последний change) +- Create: `openspec/changes/agent-v10-comments/design.md` (через `openspec new change agent-v10-comments`) + +**Interfaces:** +- Consumes: Task 1 (`comments` в состоянии). +- Produces: `AGENT_VERSION = 10`. + +- [ ] **Step 1: Падающий тест версии** + +В `tests/test_folder.py` строку 161 `self.assertEqual(agent.AGENT_VERSION, 9)` заменить на: + +```python + self.assertEqual(agent.AGENT_VERSION, 10) +``` + +Run: `/usr/bin/python3 -m unittest tests.test_folder -v` +Expected: FAIL — `AssertionError: 9 != 10`. + +- [ ] **Step 2: Версия** + +В `src/agent.py` строку `AGENT_VERSION = 9` заменить на `AGENT_VERSION = 10`. + +Run: `/usr/bin/python3 -m unittest tests.test_folder -v` +Expected: PASS. + +- [ ] **Step 3: README** + +В `README.md` найти пункт 4, начинающийся с `4. **Особенности \`refresh\`.**`, и в конец этого пункта (после предложения +про `result.missing`) дописать новую строку того же отступа: + +```markdown + С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время + и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через + `/issue/{key}/comment`. +``` + +- [ ] **Step 4: CLAUDE.md** + +В `CLAUDE.md`, раздел «Первая подсистема», заменить строку, начинающуюся с `Последний change —`, на: + +```markdown +Последний change — `../jira-hub/openspec/changes/issue-card-modal/` (агент v10: комментарии в состоянии; решения агента — `openspec/changes/agent-v10-comments/design.md`), план — `docs/superpowers/plans/2026-10-15-agent-v10-comments.md`. +``` + +- [ ] **Step 5: Change решений агента** + +Run: `openspec new change agent-v10-comments` +Expected: создан каталог `openspec/changes/agent-v10-comments/`. + +Создать `openspec/changes/agent-v10-comments/design.md`: + +```markdown +# agent-v10-comments — решения агента + +Спека и дизайн изменения — в `jira-hub/openspec/changes/issue-card-modal/` (issue aep/jira-hub#37, aep/jira-agent#27). +Агентская часть — решения 1–4 дизайна хаба (поле `comment` в `search`, дочитывание, форма комментария, версия и выкатка) +и требование `agent` «Чтение состояния». + +Решения владельца 15.10.2026 (brainstorming jira-hub#37): комментарии задачи нужны в карточке веб-приложения; два change +по репозиториям, сначала агент; автор — отображаемое имя, как в Jira над комментарием. + +## Decisions made without discussion + +Рулинги R-1…R-5 записаны в `jira-hub/openspec/changes/issue-card-modal/design.md` +(раздел «Decisions made without discussion»); здесь — только решения, принятые при исполнении агентской части. + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +``` + +(Таблица пока без строк; решения при исполнении дописываются строками `| R-6 | … |` и далее.) + +- [ ] **Step 6: Весь набор** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +Expected: `OK`. + +- [ ] **Step 7: Commit** + +```bash +git add src/agent.py tests/test_folder.py README.md CLAUDE.md openspec/changes/agent-v10-comments +git commit -m "chore(agent): версия 10, README и решения агента для комментариев (#27) + +Co-Authored-By: Claude Opus 5.5 " +``` + +## После плана + +- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #27 и на change `issue-card-modal`. +- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на + main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status` + «обновление: ok v9 → v10».