Merge pull request 'Агент v10: комментарии задач в состоянии refresh (#27)' (#28) from feat/27-agent-v10-comments into main
This commit is contained in:
commit
a799c54da7
@ -48,7 +48,7 @@
|
||||
## Первая подсистема
|
||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||
Последний change — `../jira-hub/openspec/changes/archive/2026-10-15-agent-v9-hardening/` (решения агента — `openspec/changes/archive/2026-10-15-agent-v9-hardening/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-v9-hardening-agent.md`.
|
||||
Последний 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`.
|
||||
План и спека живут в `jira-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`.
|
||||
|
||||
## Выкатка новой версии
|
||||
|
||||
@ -235,6 +235,9 @@ python3 agent.py prune
|
||||
4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
|
||||
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
|
||||
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).
|
||||
С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время
|
||||
и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через
|
||||
`/issue/{key}/comment`.
|
||||
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
|
||||
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
|
||||
завершающий перевод строки) — изменение.
|
||||
|
||||
343
docs/superpowers/plans/2026-10-15-agent-v10-comments.md
Normal file
343
docs/superpowers/plans/2026-10-15-agent-v10-comments.md
Normal file
@ -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 <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` добавить:
|
||||
|
||||
```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 <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)` заменить на:
|
||||
|
||||
```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 <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».
|
||||
2
openspec/changes/agent-v10-comments/.openspec.yaml
Normal file
2
openspec/changes/agent-v10-comments/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-15
|
||||
22
openspec/changes/agent-v10-comments/design.md
Normal file
22
openspec/changes/agent-v10-comments/design.md
Normal file
@ -0,0 +1,22 @@
|
||||
# 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-20 | 2026-10-15 | Размер пачки `POST /agent/state` с комментариями не ограничивается отдельно: пачка по-прежнему 100 задач | Риск принят в design хаба (Risks): типичные комментарии — сотни килобайт на пачку при пределе хаба 16 МБ (финальное ревью, «Declined to judge») | Если пачка превысит 16 МБ, хаб ответит 413 и упадёт весь `refresh` — зеркало перестанет обновляться; лечится уменьшением пачки |
|
||||
| R-21 | 2026-10-15 | Ограничения видимости комментариев Jira (`visibility` по группе/роли) в хаб не переносятся: комментарий уходит как есть | Хаб личный, учётка агента видит только разрешённое ей; в спеке требования нет (финальное ревью, «Declined to judge») | Комментарий с ограничением видимости виден любому с доступом к веб-приложению хаба |
|
||||
|
||||
Нумерация рулингов агентской части — с R-20, чтобы не пересечься с рулингами исполнения хабовой части (R-6 и далее в design.md хаба).
|
||||
|
||||
Финальное ревью ветки (opus, 15.10.2026), маркеры с пометкой «агент»: I-1 — fixed 28b21e0 (возвращены проверки `test_refresh_empty_keys`); M-1…M-5 — deferred (follow-up при закрытии #37).
|
||||
29
src/agent.py
29
src/agent.py
@ -33,7 +33,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
AGENT_VERSION = 9
|
||||
AGENT_VERSION = 10
|
||||
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__))
|
||||
@ -827,7 +827,7 @@ class Executor:
|
||||
|
||||
# --- refresh ---------------------------------------------------------------
|
||||
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
|
||||
"issuetype", "created", "updated", "issuelinks"]
|
||||
"issuetype", "created", "updated", "issuelinks", "comment"]
|
||||
|
||||
def state_fields(self) -> str:
|
||||
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
|
||||
@ -893,6 +893,30 @@ class Executor:
|
||||
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
|
||||
return out
|
||||
|
||||
@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]
|
||||
|
||||
def sprint_names(self, keys: List[str]) -> Dict[str, str]:
|
||||
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет,
|
||||
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение).
|
||||
@ -945,6 +969,7 @@ class Executor:
|
||||
for r in everything:
|
||||
st = self.to_state(r)
|
||||
st["sprint"] = names.get(r["key"])
|
||||
st["comments"] = self.comments_of(r)
|
||||
issues.append(st)
|
||||
for l in self.links_from(r):
|
||||
sig = (l["source_key"], l["target_key"], l["link_type"])
|
||||
|
||||
@ -38,6 +38,7 @@ class FakeJira:
|
||||
self.requests = [] # [(метод, путь)]
|
||||
self.queries = [] # параметры запросов (q) — для проверки JQL
|
||||
self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа
|
||||
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
|
||||
self.lock = threading.Lock()
|
||||
fake = self
|
||||
|
||||
@ -84,6 +85,14 @@ class FakeJira:
|
||||
self.issues[key] = base
|
||||
return base
|
||||
|
||||
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
|
||||
|
||||
def writes(self):
|
||||
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]
|
||||
|
||||
@ -91,14 +100,21 @@ class FakeJira:
|
||||
def _user(self, name):
|
||||
return {"name": name, "displayName": name} if name else None
|
||||
|
||||
def issue_json(self, key):
|
||||
def issue_json(self, key, fields=None):
|
||||
"""fields — список запрошенных полей (как параметр fields у search); None — все поля, как GET /issue/{key}."""
|
||||
f = self.issues[key]
|
||||
fields = {"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)}
|
||||
fields.update({c: f.get(c) for c in CUSTOM})
|
||||
return {"key": key, "id": f["id"], "fields": fields}
|
||||
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}
|
||||
|
||||
def links_of(self, key):
|
||||
out = []
|
||||
@ -173,7 +189,9 @@ class FakeJira:
|
||||
keys = self.search_keys(q.get("jql"))
|
||||
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
||||
page = keys[start:start + maxr]
|
||||
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]}
|
||||
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]}
|
||||
if p == "/rest/api/2/issue" and method == "POST":
|
||||
self.seq += 1
|
||||
key = "%s-%d" % (self.project, self.seq)
|
||||
@ -222,7 +240,9 @@ class FakeJira:
|
||||
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 1000))
|
||||
return 200, {"startAt": start, "maxResults": maxr, "total": len(lst), "comments": lst[start:start + maxr]}
|
||||
self.comment_seq += 1
|
||||
lst.append({"id": str(self.comment_seq), "body": body["body"]})
|
||||
ts = self.ts(self.now)
|
||||
lst.append({"id": str(self.comment_seq), "body": body["body"], "author": self._user("me"),
|
||||
"created": ts, "updated": ts})
|
||||
return 201, lst[-1]
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p)
|
||||
if m and method == "PUT":
|
||||
|
||||
@ -158,7 +158,7 @@ class FolderIntegrationTest(unittest.TestCase):
|
||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||
|
||||
def test_version_is_9(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 9)
|
||||
self.assertEqual(agent.AGENT_VERSION, 10)
|
||||
|
||||
def test_runner_through_folder(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||
|
||||
@ -81,6 +81,51 @@ class RefreshTest(unittest.TestCase):
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
self.assertEqual(self.hub.states, [])
|
||||
|
||||
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, []) # без дочитывания
|
||||
|
||||
def test_refresh_empty_jql_result(self):
|
||||
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user