jira-agent/docs/superpowers/plans/2026-10-15-agent-v10-comments.md
Andrey Epifancev 92679fdf3a docs: план agent-v10-comments (#27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:52:09 +04:00

19 KiB
Raw Blame History

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 <noreply@anthropic.com>.

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 добавить:

        self.search_comment_cap = None   # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira

Добавить метод после add:

    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 целиком:

    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, заменить строку возврата:

            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 (создание комментария) заменить две строки

            self.comment_seq += 1
            lst.append({"id": str(self.comment_seq), "body": body["body"]})

на

            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 добавить:

    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:

    STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
                    "issuetype", "created", "updated", "issuelinks", "comment"]

После метода links_from добавить:

    @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"]) добавить:

            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
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 <noreply@anthropic.com>"

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) заменить на:

        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`) дописать новую строку того же отступа:

   С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время
   и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через
   `/issue/{key}/comment`.
  • Step 4: CLAUDE.md

В CLAUDE.md, раздел «Первая подсистема», заменить строку, начинающуюся с Последний change —, на:

Последний 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:

# 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
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 <noreply@anthropic.com>"

После плана

  • Финальное ревью ветки (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».