1396 lines
83 KiB
Markdown
1396 lines
83 KiB
Markdown
# bug-and-attachments — агент v11: тип «Ошибка», вложения, устойчивое дочитывание комментариев — план
|
||
|
||
> **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:** агент v11 заводит задачи типом из `issue_types.<тип>` (в том числе `bug` — «Ошибка») с понятной ошибкой
|
||
конфигурации, выполняет новую команду `ensure_attachment` (вложение с тем же именем и размером есть — ничего; иначе файл
|
||
у хаба → проверка sha256 и длины → multipart-загрузка в Jira) и не роняет `refresh` на комментариях без `id` и на 403/404
|
||
при их дочитывании (M-1, M-2 из aep/jira-agent#30).
|
||
|
||
**Architecture:** всё в `src/agent.py`. HTTP-слой: `Http.send` — запрос с готовым телом (JSON-вызов `Http.call` идёт
|
||
через него), `JiraClient._retry` — общий повтор 429/503, `JiraClient.upload` — multipart через `multipart_file`
|
||
(граница `uuid4().hex`, `filename="…"` и `filename*=UTF-8''…`), `HubClient.file` — `GET /agent/files/{sha256}` (JSON с
|
||
base64 проходит и по HTTP, и через `FolderTransport` без изменений транспорта). Исполнитель: `issue_type_ref` бросает
|
||
`ConfigGap`, `do_ensure_attachment` + `hub_file`; повреждённый файл — `HubError` с `retryable = True`, и `Runner.execute`
|
||
повторяет команду как при обрыве связи с хабом. `comments_of` отбрасывает комментарии без `id` и возвращает `None` на
|
||
403/404 — `do_refresh` тогда не кладёт `comments`.
|
||
|
||
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; тесты — `unittest` на фейках
|
||
`tests/fake_jira.py`, `tests/fake_hub.py`, `tests/fake_relay.py`.
|
||
|
||
**Spec:** `../jira-hub/openspec/changes/bug-and-attachments/` (worktree спеки:
|
||
`/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach/openspec/changes/bug-and-attachments/`) —
|
||
`specs/agent/spec.md` (MODIFIED «Чтение состояния», ADDED «Тип задачи и вложения» — основное),
|
||
`specs/commands/spec.md` (таблица команд, «Проверки ensure_issue с типом bug и ensure_attachment при постановке» —
|
||
`size` дописывает хаб при выдаче), `specs/hub-api/spec.md` (`GET /agent/files/{sha256}` → `{file, size, content}`,
|
||
404 `no_file`), `design.md` (раздел «Агент v11», «Выкатка», «Risks»). Действующая спека агента —
|
||
`../jira-hub/openspec/specs/agent/spec.md`. Решения агентской части — `openspec/changes/agent-v11-bug-attachments/design.md`
|
||
(создаётся в Task 5).
|
||
|
||
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/bug-attach`, ветка `feat/31-bug-and-attachments`
|
||
(aep/jira-agent#31). Все команды — из корня worktree; в основные checkout'ы `jira-agent` и `jira-hub` не переходить.
|
||
|
||
## Global Constraints
|
||
|
||
- Python ≥ 3.9 (на виртуалке 3.9.6, тесты — `/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+
|
||
(`X | Y` в аннотациях функций, `match`) запрещён — `tests/test_py39.py`.
|
||
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор.
|
||
- `AGENT_VERSION` SHALL быть 11.
|
||
- Тип для `ensure_issue` — из `issue_types.<issue_type>`: `{id}`, если `id` задан, иначе `{name}`; нет записи или в ней
|
||
нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.<issue_type>», `failed` без вызова Jira.
|
||
- `ensure_attachment {key, file, filename, size}` (`size` дописан хабом при выдаче): `size = null` — `failed` «файл удалён
|
||
с хаба, загрузите заново» без вызова Jira; `GET /rest/api/2/issue/{key}?fields=attachment`, совпадение `filename` и
|
||
`size` — `done` с `attachment_id`, файл у хаба не запрашивается; файл — `GET /agent/files/{file}`, sha256 и длина
|
||
декодированного `content` SHALL совпасть с `file` и `size`, иначе временный сбой (повтор, как обрыв связи с хабом);
|
||
404 — `failed` «файл удалён с хаба, загрузите заново»; `POST /rest/api/2/issue/{key}/attachments`, `multipart/form-data`
|
||
с одной частью `file`, имя файла — `filename` в UTF-8 (`filename="…"` и `filename*=UTF-8''…`), заголовок
|
||
`X-Atlassian-Token: no-check`; результат `attachment_id` из ответа; ответ Jira 4xx (в том числе 413) — `failed` с кодом
|
||
и текстом Jira.
|
||
- Граница multipart — `uuid4().hex`; у части `Content-Type: application/octet-stream`; таймаут загрузки — `max(timeout, 120)` с;
|
||
ответ Jira — список вложений, берётся `id` первого (design «Агент v11»).
|
||
- Содержимое файла MUST NOT попадать в журнал и лог (только `file`, `filename`, размер).
|
||
- Комментарий без `id` или с пустым `id` — отбросить с записью в лог (ключ задачи); 403 или 404 при дочитывании
|
||
комментариев — задача без поля `comments` с записью в лог, `refresh` продолжается.
|
||
- Коммиты на русском с `(#31)`, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||
|
||
## Review Focus
|
||
|
||
1. Jira приняла файл, но соединение оборвалось до ответа — повтор через проверку находит вложение по имени и размеру,
|
||
второго вложения нет, файл у хаба второй раз не берётся (Task 4, `test_attachment_upload_dropped_no_duplicate`).
|
||
2. Имя файла с кавычкой или переводом строки — multipart не ломается (одна часть, тело цело), а повтор узнаёт уже
|
||
загруженное вложение и под «браузерной» формой имени (Task 3, `test_upload_filename_with_quote_and_newline`;
|
||
Task 4, `test_existing_under_wire_name`).
|
||
3. Хаб ответил 2xx, но тело не JSON (обрезанный ответ) — временный сбой; начало тела (то есть содержимого файла в base64)
|
||
не попадает в текст ошибки, журнал и лог (Task 4, `test_hub_non_json_file_no_content_in_error`).
|
||
4. Jira ответила 429 на загрузку — повтор по `Retry-After` тем же правилом, что у остальных вызовов; одно вложение
|
||
(Task 3, `test_upload_retries_429`).
|
||
5. Ответ Jira на загрузку — не список или без `id` — понятный `failed`, а не «внутренняя ошибка агента: KeyError»
|
||
(Task 4, `test_upload_response_without_id`).
|
||
|
||
---
|
||
|
||
### Task 1: Устойчивое дочитывание комментариев в `refresh` (M-1, M-2)
|
||
|
||
**Модель исполнителя:** haiku (готовый код).
|
||
|
||
**Files:**
|
||
- Modify: `tests/fake_jira.py` (`comment_denied` — код ответа на `GET /issue/{key}/comment`)
|
||
- Modify: `src/agent.py` — `Executor.comments_of` (стр. 903–918), цикл сборки состояния в `do_refresh` (стр. 969–973)
|
||
- Modify: `README.md` — пункт 4 «Особенности `refresh`» раздела «Известные ограничения / эксплуатация»
|
||
- Test: `tests/test_refresh.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: —
|
||
- Produces: `Executor.comments_of(raw: dict) -> Optional[List[dict]]` (`None` — комментарии не дочитаны из-за 403/404,
|
||
поле `comments` в состояние не кладётся); фейк `FakeJira.comment_denied: Dict[str, int]`.
|
||
|
||
- [ ] **Step 1: Фейк Jira — отказ в чтении комментариев**
|
||
|
||
В `tests/fake_jira.py`, в `FakeJira.__init__` после строки `self.search_comment_cap = None …` добавить:
|
||
|
||
```python
|
||
self.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи)
|
||
```
|
||
|
||
В `route`, в обработчике `^/rest/api/2/issue/([^/]+)/comment$` заменить начало
|
||
|
||
```python
|
||
if m:
|
||
key = m.group(1)
|
||
self.issues[key]
|
||
```
|
||
|
||
на
|
||
|
||
```python
|
||
if m:
|
||
key = m.group(1)
|
||
if method == "GET" and key in self.comment_denied:
|
||
return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]}
|
||
self.issues[key]
|
||
```
|
||
|
||
- [ ] **Step 2: Падающие тесты**
|
||
|
||
В `tests/test_refresh.py` после `test_comments_field_missing` добавить:
|
||
|
||
```python
|
||
def test_comment_without_id_dropped(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add_comment("AURORA-1", "нормальный")
|
||
self.fake.add_comment("AURORA-1", "второй")["id"] = None
|
||
self.fake.add_comment("AURORA-1", "третий")["id"] = ""
|
||
logs = []
|
||
orig = agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
out = self.ex.run({"id": 10, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
finally:
|
||
agent.log = orig
|
||
self.assertEqual(out["status"], "done")
|
||
c = self.hub.states[0]["issues"][0]["comments"]
|
||
self.assertEqual([x["body"] for x in c], ["нормальный"])
|
||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "комментарий без id" in l]), 2, logs)
|
||
|
||
def test_comments_read_denied_keeps_refresh(self):
|
||
for key in ("AURORA-1", "AURORA-2", "AURORA-3"):
|
||
self.fake.add(key)
|
||
for n in range(3):
|
||
self.fake.add_comment(key, "%s-%d" % (key, n))
|
||
self.fake.search_comment_cap = 1 # search отдаёт 1 из 3 — нужно дочитывание
|
||
self.fake.comment_denied = {"AURORA-1": 403, "AURORA-2": 404}
|
||
logs = []
|
||
orig = agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-3"]}})
|
||
finally:
|
||
agent.log = orig
|
||
self.assertEqual(out["status"], "done")
|
||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||
self.assertNotIn("comments", by_key["AURORA-1"])
|
||
self.assertNotIn("comments", by_key["AURORA-2"])
|
||
self.assertEqual([x["body"] for x in by_key["AURORA-3"]["comments"]], ["AURORA-3-0", "AURORA-3-1", "AURORA-3-2"])
|
||
self.assertTrue(any("AURORA-1" in l and "HTTP 403" in l for l in logs), logs)
|
||
self.assertTrue(any("AURORA-2" in l and "HTTP 404" in l for l in logs), logs)
|
||
|
||
def test_comments_read_5xx_still_fails_refresh(self):
|
||
self.fake.add("AURORA-1")
|
||
for n in range(3):
|
||
self.fake.add_comment("AURORA-1", "к%d" % n)
|
||
self.fake.search_comment_cap = 1
|
||
self.fake.comment_denied = {"AURORA-1": 500}
|
||
out = self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["retryable"]) # 5xx — по-прежнему повтор через проверку (R-10)
|
||
self.assertEqual(self.hub.states, [])
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||
Expected: FAIL — `test_comment_without_id_dropped` (в состоянии три комментария, id `"None"` и `""`),
|
||
`test_comments_read_denied_keeps_refresh` (`status` = `failed`, `HTTP 403`); `test_comments_read_5xx_still_fails_refresh` — PASS уже сейчас.
|
||
|
||
- [ ] **Step 3: Реализация**
|
||
|
||
В `src/agent.py` заменить метод `comments_of` целиком:
|
||
|
||
```python
|
||
def comments_of(self, raw: dict) -> Optional[List[dict]]:
|
||
"""Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично
|
||
через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30);
|
||
403/404 при дочитывании — None: задача уходит без comments, хаб оставит прежние (M-2 #30)."""
|
||
key = raw["key"]
|
||
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
|
||
try:
|
||
while True:
|
||
page = self.jira.get(API + "/issue/%s/comment" % 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
|
||
except HttpError as e:
|
||
if e.code not in (403, 404):
|
||
raise
|
||
log("refresh: %s — комментарии не дочитаны (HTTP %d), задача уходит без comments" % (key, e.code))
|
||
return None
|
||
out = []
|
||
for c in got:
|
||
if c.get("id") is None or str(c.get("id")) == "":
|
||
log("refresh: %s — комментарий без id пропущен" % key)
|
||
continue
|
||
out.append(self.comment_state(c))
|
||
return out
|
||
```
|
||
|
||
В `do_refresh` заменить строку
|
||
|
||
```python
|
||
st["comments"] = self.comments_of(r)
|
||
```
|
||
|
||
на
|
||
|
||
```python
|
||
comments = self.comments_of(r)
|
||
if comments is not None: # None — нет доступа к комментариям: поле не передаём (M-2 #30)
|
||
st["comments"] = comments
|
||
```
|
||
|
||
- [ ] **Step 4: Тесты проходят**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||
Expected: PASS (все тесты файла, включая прежние `test_comments_*`).
|
||
|
||
- [ ] **Step 5: README**
|
||
|
||
В `README.md`, пункт `4. **Особенности \`refresh\`.**`, после предложения, которое заканчивается
|
||
`` агент дочитывает их через `/issue/{key}/comment`. ``, дописать строки того же отступа (3 пробела):
|
||
|
||
```markdown
|
||
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
|
||
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/agent.py tests/fake_jira.py tests/test_refresh.py README.md
|
||
git commit -m "fix(agent): refresh не падает на комментариях без id и на 403/404 при дочитывании (M-1, M-2 #30, #31)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Тип задачи из настроек хаба, тип `bug`
|
||
|
||
**Модель исполнителя:** haiku (готовый код).
|
||
|
||
**Files:**
|
||
- Modify: `tests/_common.py` (`SETTINGS["issue_types"]` — `bug`)
|
||
- Modify: `src/agent.py` — `Executor.issue_type_ref` (стр. 574–576), начало `do_ensure_issue` (стр. 595–609)
|
||
- Modify: `README.md` — строка `ensure_issue` таблицы «Что делает агент с командами»
|
||
- Test: `tests/test_executor_fields.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: —
|
||
- Produces: `Executor.issue_type_ref(kind: str) -> dict` — `{"id": str}` или `{"name": str}`, иначе `ConfigGap`
|
||
«нет настройки issue_types.<kind>»; `SETTINGS["issue_types"]["bug"] = {"name": "Ошибка", "id": None}`.
|
||
|
||
- [ ] **Step 1: Настройки тестов**
|
||
|
||
В `tests/_common.py` заменить строку `"issue_types": …` на:
|
||
|
||
```python
|
||
"issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"},
|
||
"bug": {"name": "Ошибка", "id": None}},
|
||
```
|
||
|
||
- [ ] **Step 2: Падающие тесты**
|
||
|
||
В `tests/test_executor_fields.py` после `test_ensure_issue_priority_mapped` добавить:
|
||
|
||
```python
|
||
def test_ensure_issue_bug_by_name(self):
|
||
self.assertEqual(self.ex.issue_type_ref("bug"), {"name": "Ошибка"}) # id нет — по имени
|
||
self.assertEqual(self.ex.issue_type_ref("task"), {"id": "10100"}) # id есть — по id
|
||
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="ОСВ 60 не распознана",
|
||
labels=["AWPCA"])
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertTrue(out["result"]["created"])
|
||
self.assertEqual(self.fake.issues[out["result"]["key"]]["issuetype"], "Ошибка")
|
||
|
||
def test_ensure_issue_type_not_configured_no_jira_calls(self):
|
||
self.ex.s = dict(SETTINGS, issue_types={"task": {"name": "Задача", "id": "10100"}})
|
||
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="x")
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(out["error"], "нет настройки issue_types.bug")
|
||
self.assertNotIn("retryable", out)
|
||
self.assertEqual(self.fake.requests, []) # ни поиска дубля, ни создания
|
||
|
||
def test_issue_type_entry_without_name_and_id(self):
|
||
for entry in ({}, {"name": None, "id": None}, {"name": "", "id": ""}, None, "Ошибка"):
|
||
self.ex.s = dict(SETTINGS, issue_types={"bug": entry})
|
||
with self.assertRaises(agent.ConfigGap) as cm:
|
||
self.ex.issue_type_ref("bug")
|
||
self.assertEqual(str(cm.exception), "нет настройки issue_types.bug")
|
||
self.ex.s = dict(SETTINGS)
|
||
del self.ex.s["issue_types"]
|
||
with self.assertRaises(agent.ConfigGap):
|
||
self.ex.issue_type_ref("task")
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v`
|
||
Expected: FAIL — `test_ensure_issue_type_not_configured_no_jira_calls` (сейчас уходит поиск и `POST` с
|
||
`{"name": None}`), `test_issue_type_entry_without_name_and_id` (`ConfigGap` не брошен).
|
||
|
||
- [ ] **Step 3: Реализация**
|
||
|
||
В `src/agent.py` заменить `issue_type_ref` целиком:
|
||
|
||
```python
|
||
def issue_type_ref(self, kind: str) -> dict:
|
||
"""Тип задачи Jira из issue_types.<kind>: {id}, если id задан, иначе {name}; нет ни того, ни другого —
|
||
ошибка конфигурации, а не {"name": None}, на которое Jira ответит 400 без понятной причины (спека agent v11)."""
|
||
it = (self.s.get("issue_types") or {}).get(kind)
|
||
if isinstance(it, dict) and it.get("id"):
|
||
return {"id": str(it["id"])}
|
||
if isinstance(it, dict) and it.get("name"):
|
||
return {"name": it["name"]}
|
||
raise ConfigGap("нет настройки issue_types.%s" % kind)
|
||
```
|
||
|
||
В `do_ensure_issue` первые строки метода
|
||
|
||
```python
|
||
def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
|
||
summary = (p["summary"] or "").strip()
|
||
```
|
||
|
||
заменить на
|
||
|
||
```python
|
||
def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
|
||
issuetype = self.issue_type_ref(p["issue_type"]) # до поиска дубля: без настройки — ни одного вызова Jira
|
||
summary = (p["summary"] or "").strip()
|
||
```
|
||
|
||
и в той же функции строку
|
||
|
||
```python
|
||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]),
|
||
"summary": p["summary"]}
|
||
```
|
||
|
||
на
|
||
|
||
```python
|
||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": issuetype, "summary": p["summary"]}
|
||
```
|
||
|
||
- [ ] **Step 4: Тесты проходят**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: README**
|
||
|
||
В `README.md`, таблица раздела «Что делает агент с командами», строку `ensure_issue` заменить на:
|
||
|
||
```markdown
|
||
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` |
|
||
```
|
||
|
||
- [ ] **Step 6: Весь набор**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||
Expected: `OK` (новая запись `bug` в `SETTINGS` не меняет прежних тестов).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/agent.py tests/_common.py tests/test_executor_fields.py README.md
|
||
git commit -m "feat(agent): тип задачи из issue_types с ошибкой конфигурации, тип bug (#31)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: HTTP — multipart-загрузка в Jira и файл у хаба
|
||
|
||
**Модель исполнителя:** haiku (готовый код).
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` — импорт `typing` (стр. 34), `Http.call` (стр. 114–145), `JiraClient.call` (стр. 187–204),
|
||
новые `wire_filename`, `multipart_file` (перед `class Http`), `HubClient.file` (после `known_keys`, стр. 330–331)
|
||
- Modify: `tests/fake_jira.py` (разбор multipart, вложения, `POST /issue/{key}/attachments`, проверка `X-Atlassian-Token`)
|
||
- Modify: `tests/fake_hub.py` (хранилище файлов, `GET /agent/files/{sha}`, `size` при выдаче `ensure_attachment`)
|
||
- Modify: `ARCHITECTURE.md` — строка «HTTP» таблицы разделов
|
||
- Test: `tests/test_clients.py`, `tests/test_folder.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: —
|
||
- Produces:
|
||
- `wire_filename(name: str) -> str` — имя для `filename="…"`: `"` → `%22`, `\r` → `%0D`, `\n` → `%0A`, остальное как есть.
|
||
- `multipart_file(boundary: str, filename: str, data: bytes) -> bytes` — тело с одной частью `file`.
|
||
- `Http.send(method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None, timeout=None) -> Any`
|
||
— запрос с готовым телом, ответ — JSON (ошибки как у `Http.call`).
|
||
- `JiraClient._retry(request: Callable[[], Any]) -> Any` — повтор 429/503 по `Retry-After` (до 5 запросов).
|
||
- `JiraClient.upload(key: str, filename: str, data: bytes) -> Any` — `POST /rest/api/2/issue/{key}/attachments`,
|
||
таймаут `max(self.timeout, 120)`; возвращает ответ Jira как есть (ожидается список вложений).
|
||
- `HubClient.file(sha256: str) -> dict` — `GET /agent/files/{sha256}` → `{file, size, content}`.
|
||
- Фейк Jira: `FakeJira.attachments: Dict[str, List[dict]]` (`{id, filename, size, mimeType, data}`),
|
||
`add_attachment(key, filename, data, mime="application/octet-stream") -> dict`, `attachment_json(a) -> dict`,
|
||
`uploads: List[dict]` (`{key, token, content_type, headers, disposition}`), `drop_next_post_attachment: bool`,
|
||
`attachment_limit: Optional[int]`, `bad_attachment_response: bool`; модульные `parse_multipart(raw, content_type)`,
|
||
`disposition_params(value)`.
|
||
- Фейк хаба: `FakeHub.files: Dict[str, bytes]`, `put_file(data: bytes) -> str` (sha256), `file_requests: int`;
|
||
при выдаче `ensure_attachment` (`claim` и `GET /agent/commands/{id}`) к `params` добавляется `size` — длина файла
|
||
или `None`, если файла нет (как хаб, `commands`).
|
||
|
||
- [ ] **Step 1: Фейк Jira — вложения и multipart**
|
||
|
||
В `tests/fake_jira.py`:
|
||
|
||
1. Импорт: строку `from urllib.parse import parse_qs, urlparse` заменить на
|
||
|
||
```python
|
||
from urllib.parse import parse_qs, unquote, urlparse
|
||
```
|
||
|
||
2. После `CUSTOM = (…)` добавить функции модуля:
|
||
|
||
```python
|
||
def parse_multipart(raw, content_type):
|
||
"""Строгий разбор multipart/form-data, как commons-fileupload Jira Server с кодировкой заголовков UTF-8:
|
||
[{"headers": {имя в нижнем регистре: значение}, "data": bytes}]. Нарушение формата — AssertionError (фейк ответит 400)."""
|
||
m = re.search(r"boundary=([^;\s]+)", content_type)
|
||
assert m, "нет boundary в Content-Type"
|
||
delim = b"--" + m.group(1).encode("ascii")
|
||
head, tail = delim + b"\r\n", b"\r\n" + delim + b"--\r\n"
|
||
assert raw.startswith(head), "тело не начинается с границы"
|
||
assert raw.endswith(tail), "нет закрывающей границы"
|
||
parts = []
|
||
for chunk in raw[len(head):len(raw) - len(tail)].split(b"\r\n" + delim + b"\r\n"):
|
||
hdr, sep, data = chunk.partition(b"\r\n\r\n")
|
||
assert sep, "у части нет пустой строки после заголовков"
|
||
headers = {}
|
||
for line in hdr.decode("utf-8").split("\r\n"):
|
||
name, _, value = line.partition(":")
|
||
headers[name.strip().lower()] = value.strip()
|
||
parts.append({"headers": headers, "data": data})
|
||
return parts
|
||
|
||
|
||
def disposition_params(value):
|
||
"""Параметры Content-Disposition части: name; filename — как в кавычках, без percent-декодирования (так читает
|
||
Jira Server); filename* — после UTF-8'' с percent-декодированием."""
|
||
out = {}
|
||
m = re.search(r'(?:^|;)\s*name="([^"]*)"', value)
|
||
if m:
|
||
out["name"] = m.group(1)
|
||
m = re.search(r'(?:^|;)\s*filename="([^"]*)"', value)
|
||
if m:
|
||
out["filename"] = m.group(1)
|
||
m = re.search(r"(?:^|;)\s*filename\*=UTF-8''([^;\s]+)", value)
|
||
if m:
|
||
out["filename*"] = unquote(m.group(1), encoding="utf-8", errors="strict")
|
||
return out
|
||
```
|
||
|
||
3. В `FakeJira.__init__` после строки `self.comment_denied = {} …` (Task 1) добавить:
|
||
|
||
```python
|
||
self.attachments = {} # ключ → [{id, filename, size, mimeType, data}]
|
||
self.attachment_seq = 20000
|
||
self.uploads = [] # разобранные POST /attachments: {key, token, content_type, headers, disposition}
|
||
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
|
||
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
|
||
self.bad_attachment_response = False # ответить на загрузку пустым списком
|
||
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
|
||
```
|
||
|
||
4. После метода `add_comment` добавить:
|
||
|
||
```python
|
||
def add_attachment(self, key, filename, data, mime="application/octet-stream"):
|
||
self.attachment_seq += 1
|
||
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data}
|
||
self.attachments.setdefault(key, []).append(a)
|
||
return a
|
||
|
||
def attachment_json(self, a):
|
||
return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"],
|
||
"filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"]}
|
||
```
|
||
|
||
5. В `issue_json` перед строкой `return {"key": key, "id": f["id"], "fields": out}` добавить:
|
||
|
||
```python
|
||
if fields is None or "attachment" in fields:
|
||
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
||
```
|
||
|
||
6. В `handle` заменить строки
|
||
|
||
```python
|
||
length = int(h.headers.get("Content-Length") or 0)
|
||
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
|
||
with self.lock:
|
||
self.requests.append((method, url.path))
|
||
```
|
||
|
||
на
|
||
|
||
```python
|
||
length = int(h.headers.get("Content-Length") or 0)
|
||
raw = h.rfile.read(length) if length else b""
|
||
ctype = h.headers.get("Content-Type") or ""
|
||
if ctype.startswith("multipart/form-data"):
|
||
body = {"_raw": raw, "_content_type": ctype} # разбирает маршрут: ошибка формата — 400
|
||
else:
|
||
body = json.loads(raw.decode("utf-8")) if raw else {}
|
||
with self.lock:
|
||
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
|
||
self.requests.append((method, url.path))
|
||
```
|
||
|
||
и после блока `if self.drop_next_post_issue and …: … return` (перед `self._send(h, code, payload)`) добавить:
|
||
|
||
```python
|
||
if self.drop_next_post_attachment and method == "POST" and url.path.endswith("/attachments"):
|
||
self.drop_next_post_attachment = False
|
||
h.close_connection = True
|
||
h.connection.shutdown(socket.SHUT_RDWR) # вложение сохранено, ответа клиент не получит
|
||
return
|
||
```
|
||
|
||
7. В `route` после обработчика `^/rest/api/2/issue/([^/]+)/comment/(\d+)$` добавить:
|
||
|
||
```python
|
||
m = re.match(r"^/rest/api/2/issue/([^/]+)/attachments$", p)
|
||
if m and method == "POST":
|
||
key = m.group(1)
|
||
self.issues[key]
|
||
if self.last_headers.get("x-atlassian-token") != "no-check":
|
||
return 403, {"errorMessages": ["XSRF check failed"]} # как Jira Server без заголовка
|
||
parts = parse_multipart(body["_raw"], body["_content_type"])
|
||
assert len(parts) == 1, "ожидалась одна часть, пришло %d" % len(parts)
|
||
part = parts[0]
|
||
disp = disposition_params(part["headers"].get("content-disposition", ""))
|
||
assert disp.get("name") == "file", "часть называется не file: %r" % disp
|
||
self.uploads.append({"key": key, "token": self.last_headers.get("x-atlassian-token"),
|
||
"content_type": body["_content_type"], "headers": part["headers"], "disposition": disp})
|
||
if self.attachment_limit is not None and len(part["data"]) > self.attachment_limit:
|
||
return 413, {"errorMessages": ["Размер вложения превышает предел %d байт" % self.attachment_limit]}
|
||
a = self.add_attachment(key, disp["filename"], part["data"],
|
||
part["headers"].get("content-type") or "application/octet-stream")
|
||
if self.bad_attachment_response:
|
||
return 200, []
|
||
return 200, [self.attachment_json(a)]
|
||
```
|
||
|
||
- [ ] **Step 2: Фейк хаба — файлы**
|
||
|
||
В `tests/fake_hub.py`:
|
||
|
||
1. Импорты: перед `import json` добавить
|
||
|
||
```python
|
||
import base64
|
||
import hashlib
|
||
```
|
||
|
||
2. В `FakeHub.__init__` после `self.claim_wait = 0 …` добавить:
|
||
|
||
```python
|
||
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
|
||
self.file_requests = 0
|
||
```
|
||
|
||
3. После метода `enqueue` добавить:
|
||
|
||
```python
|
||
def put_file(self, data):
|
||
sha = hashlib.sha256(data).hexdigest()
|
||
self.files[sha] = data
|
||
return sha
|
||
|
||
def _issued(self, c):
|
||
"""Параметры при выдаче: как хаб, к ensure_attachment дописывается size файла (None — файла нет)."""
|
||
params = dict(c["params"])
|
||
if c["type"] == "ensure_attachment":
|
||
data = self.files.get(params.get("file"))
|
||
params["size"] = len(data) if data is not None else None
|
||
return params
|
||
```
|
||
|
||
4. В `route`, в ветке `/agent/claim`, строку
|
||
|
||
```python
|
||
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
|
||
```
|
||
|
||
заменить на
|
||
|
||
```python
|
||
return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
|
||
```
|
||
|
||
5. В `route` ветку `^/agent/commands/(\d+)$`
|
||
|
||
```python
|
||
if m:
|
||
c = self.commands.get(int(m.group(1)))
|
||
return (200, c) if c else (404, {"error": "not_found"})
|
||
```
|
||
|
||
заменить на
|
||
|
||
```python
|
||
if m:
|
||
c = self.commands.get(int(m.group(1)))
|
||
return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
|
||
```
|
||
|
||
6. В `route` перед `m = re.match(r"^/agent/commands/(\d+)$", path)` добавить:
|
||
|
||
```python
|
||
m = re.match(r"^/agent/files/([^/]+)$", path)
|
||
if m and method == "GET":
|
||
self.file_requests += 1
|
||
data = self.files.get(m.group(1))
|
||
if data is None:
|
||
return 404, {"error": "no_file", "detail": "файла %s нет в хранилище" % m.group(1)}
|
||
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
|
||
```
|
||
|
||
- [ ] **Step 3: Падающие тесты клиентов**
|
||
|
||
В `tests/test_clients.py` импорты заменить на:
|
||
|
||
```python
|
||
import unittest
|
||
import urllib.parse
|
||
|
||
from _common import agent, jira_cfg, no_sleep
|
||
from fake_jira import FakeJira
|
||
```
|
||
|
||
и в класс `ClientsTest` (после `test_get_and_counters`) добавить:
|
||
|
||
```python
|
||
def test_multipart_file_bytes(self):
|
||
body = agent.multipart_file("b0", "Отчёт.txt", b"x\r\ny")
|
||
q = urllib.parse.quote("Отчёт.txt", safe="")
|
||
expected = ('--b0\r\nContent-Disposition: form-data; name="file"; filename="Отчёт.txt"; '
|
||
"filename*=UTF-8''%s\r\nContent-Type: application/octet-stream\r\n\r\n" % q).encode("utf-8") \
|
||
+ b"x\r\ny" + b"\r\n--b0--\r\n"
|
||
self.assertEqual(body, expected)
|
||
|
||
def test_wire_filename(self):
|
||
self.assertEqual(agent.wire_filename('а "б"\r\n.txt'), "а %22б%22%0D%0A.txt")
|
||
self.assertEqual(agent.wire_filename("ОСВ 60.xlsx"), "ОСВ 60.xlsx")
|
||
|
||
def test_upload_cyrillic_name(self):
|
||
self.fake.add("AURORA-1")
|
||
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
|
||
data = bytes(range(256)) * 40
|
||
resp = self.jira.upload("AURORA-1", name, data)
|
||
self.assertIsInstance(resp, list)
|
||
a = self.fake.attachments["AURORA-1"][0]
|
||
self.assertEqual((resp[0]["id"], a["filename"], a["size"], a["data"]), (a["id"], name, len(data), data))
|
||
up = self.fake.uploads[-1]
|
||
self.assertEqual(up["token"], "no-check")
|
||
self.assertTrue(up["content_type"].startswith("multipart/form-data; boundary="))
|
||
self.assertEqual(len(up["content_type"].split("boundary=")[1]), 32) # uuid4().hex
|
||
self.assertEqual(up["headers"]["content-type"], "application/octet-stream")
|
||
self.assertEqual(up["disposition"]["filename*"], name)
|
||
self.assertEqual(self.jira.calls, 1)
|
||
self.assertEqual(self.fake.requests[-1], ("POST", "/rest/api/2/issue/AURORA-1/attachments"))
|
||
|
||
def test_upload_filename_with_quote_and_newline(self):
|
||
self.fake.add("AURORA-1")
|
||
name = 'итог "кв.2"\r\n.txt'
|
||
self.jira.upload("AURORA-1", name, b"abc")
|
||
up = self.fake.uploads[-1]
|
||
self.assertEqual(up["disposition"]["filename"], "итог %22кв.2%22%0D%0A.txt")
|
||
self.assertEqual(up["disposition"]["filename*"], name)
|
||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||
self.assertEqual(self.fake.attachments["AURORA-1"][0]["data"], b"abc")
|
||
|
||
def test_upload_needs_xsrf_header(self):
|
||
self.fake.add("AURORA-1")
|
||
self.jira.headers = {k: v for k, v in self.jira.headers.items() if k != "X-Atlassian-Token"}
|
||
with self.assertRaises(agent.HttpError) as cm:
|
||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||
self.assertEqual(cm.exception.code, 403)
|
||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||
|
||
def test_upload_retries_429(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||
self.assertEqual(self.jira.rate_limited, 1)
|
||
self.assertEqual(self.jira.calls, 2)
|
||
|
||
def test_upload_timeout_at_least_120(self):
|
||
self.fake.add("AURORA-1")
|
||
seen = []
|
||
orig = self.jira.send
|
||
|
||
def spy(*a, **kw):
|
||
seen.append(kw.get("timeout"))
|
||
return orig(*a, **kw)
|
||
|
||
self.jira.send = spy
|
||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||
self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_clients -v`
|
||
Expected: FAIL — `AttributeError: module 'agent' has no attribute 'multipart_file'` / `'wire_filename'`,
|
||
`'JiraClient' object has no attribute 'upload'`.
|
||
|
||
- [ ] **Step 4: Падающие тесты файла у хаба (HTTP и папка)**
|
||
|
||
В `tests/test_folder.py` в импорты (после `import io`) добавить `import base64`; в класс `FolderTransportTest` после
|
||
`test_http_url_keeps_http_transport` добавить:
|
||
|
||
```python
|
||
def test_file_through_folder(self):
|
||
self.run_relay()
|
||
data = os.urandom(300 * 1024)
|
||
sha = self.hub.put_file(data)
|
||
got = self.client().file(sha)
|
||
self.assertEqual((got["file"], got["size"]), (sha, len(data)))
|
||
self.assertEqual(base64.b64decode(got["content"]), data)
|
||
self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha)
|
||
self.assertEqual(self.relay.requests[0]["method"], "GET")
|
||
|
||
def test_file_missing_through_folder(self):
|
||
self.run_relay()
|
||
with self.assertRaises(agent.HttpError) as e:
|
||
self.client().file("0" * 64)
|
||
self.assertEqual(e.exception.code, 404)
|
||
self.assertIn("no_file", e.exception.text)
|
||
|
||
def test_file_over_http(self):
|
||
sha = self.hub.put_file(b"abc")
|
||
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||
got = hub.file(sha)
|
||
self.assertEqual(base64.b64decode(got["content"]), b"abc")
|
||
self.assertEqual(self.hub.file_requests, 1)
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||
Expected: FAIL — `AttributeError: 'HubClient' object has no attribute 'file'`.
|
||
|
||
- [ ] **Step 5: Реализация в `src/agent.py`**
|
||
|
||
1. Импорт `typing` (стр. 34) заменить на:
|
||
|
||
```python
|
||
from typing import Any, Callable, Dict, List, Optional
|
||
```
|
||
|
||
2. Перед `class HttpError` (раздел `# HTTP`) добавить:
|
||
|
||
```python
|
||
def wire_filename(name: str) -> str:
|
||
"""Имя для filename="…" в multipart: кавычка и переводы строк — %22, %0D, %0A, как у браузеров (HTML5);
|
||
остальное — UTF-8 как есть (Jira Server читает filename в кодировке запроса)."""
|
||
return name.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A")
|
||
|
||
|
||
def multipart_file(boundary: str, filename: str, data: bytes) -> bytes:
|
||
"""Тело multipart/form-data с одной частью file (спека agent «Тип задачи и вложения»)."""
|
||
disposition = "form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s" % (
|
||
wire_filename(filename), urllib.parse.quote(filename, safe=""))
|
||
head = "--%s\r\nContent-Disposition: %s\r\nContent-Type: application/octet-stream\r\n\r\n" % (boundary, disposition)
|
||
return head.encode("utf-8") + data + ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||
```
|
||
|
||
3. В `class Http` заменить метод `call` целиком на два метода:
|
||
|
||
```python
|
||
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
|
||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||
return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None,
|
||
params, timeout)
|
||
|
||
def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None,
|
||
timeout=None) -> Any:
|
||
"""Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError."""
|
||
url = self.base + path
|
||
if params:
|
||
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||
req = urllib.request.Request(url, data=data, method=method)
|
||
for k, v in self.headers.items():
|
||
req.add_header(k, v)
|
||
req.add_header("Accept", "application/json")
|
||
if content_type is not None:
|
||
req.add_header("Content-Type", content_type)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp:
|
||
raw = resp.read()
|
||
status = resp.status
|
||
except urllib.error.HTTPError as e:
|
||
self._touch()
|
||
try:
|
||
headers = {k.lower(): v for k, v in e.headers.items()}
|
||
text = e.read().decode("utf-8", "replace")
|
||
except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err:
|
||
raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None
|
||
finally:
|
||
e.close()
|
||
raise HttpError(e.code, text, headers) from None
|
||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e:
|
||
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
|
||
self._touch()
|
||
try:
|
||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||
except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError
|
||
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None
|
||
```
|
||
|
||
4. В `class JiraClient` заменить метод `call` целиком на:
|
||
|
||
```python
|
||
def _retry(self, request: Callable[[], Any]) -> Any:
|
||
"""429/503 — повтор по Retry-After (до 5 запросов, не более 4 ожиданий); прочие ошибки — наружу."""
|
||
for attempt in range(1, 6):
|
||
try:
|
||
return request()
|
||
except HttpError as e:
|
||
if e.code in (429, 503) and attempt < 5:
|
||
wait = 60 * attempt
|
||
try:
|
||
wait = max(0, int(e.headers.get("retry-after")))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
self.rate_limited += 1
|
||
log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
|
||
self.sleep(wait)
|
||
continue
|
||
if e.code in (429, 503):
|
||
self.rate_limited += 1
|
||
raise
|
||
|
||
def call(self, method, path, body=None, params=None):
|
||
return self._retry(lambda: Http.call(self, method, path, body, params))
|
||
|
||
def upload(self, key: str, filename: str, data: bytes) -> Any:
|
||
"""Вложение в задачу: multipart, X-Atlassian-Token: no-check (заголовок клиента), таймаут не меньше 120 с.
|
||
Содержимое не логируется."""
|
||
boundary = uuid.uuid4().hex
|
||
body = multipart_file(boundary, filename, data)
|
||
return self._retry(lambda: self.send("POST", API + "/issue/%s/attachments" % key, body,
|
||
"multipart/form-data; boundary=" + boundary,
|
||
timeout=max(self.timeout, 120)))
|
||
```
|
||
|
||
5. В `class HubClient` после метода `known_keys` добавить:
|
||
|
||
```python
|
||
def file(self, sha256: str) -> dict:
|
||
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
|
||
return self.call("GET", "/agent/files/%s" % sha256)
|
||
```
|
||
|
||
- [ ] **Step 6: Тесты проходят**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_clients tests.test_folder -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: ARCHITECTURE.md**
|
||
|
||
В `ARCHITECTURE.md` строку таблицы `| HTTP | …` заменить на:
|
||
|
||
```markdown
|
||
| 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 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
|
||
```
|
||
|
||
- [ ] **Step 8: Весь набор**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/agent.py tests/fake_jira.py tests/fake_hub.py tests/test_clients.py tests/test_folder.py ARCHITECTURE.md
|
||
git commit -m "feat(agent): multipart-загрузка вложения в Jira и файл вложения у хаба (#31)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Команда `ensure_attachment` в исполнителе и цикле
|
||
|
||
**Модель исполнителя:** sonnet (интеграция: исполнитель, повтор в `Runner.execute`, журнал и лог).
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` — константа `FILE_GONE` (после `class HubError`), `Executor.hub_file` и
|
||
`Executor.do_ensure_attachment` (новый раздел `# --- ensure_attachment` после `do_ensure_sprint`, перед `# --- refresh`)
|
||
- Modify: `tests/_common.py` (`ZERO_PACING` — `ensure_attachment`)
|
||
- Create: `tests/test_executor_attachments.py`
|
||
- Modify: `tests/test_runner.py` (повтор после обрыва, повреждённый файл, содержимое не в журнале и логе)
|
||
- Modify: `README.md` (строка `ensure_attachment` таблицы команд), `ARCHITECTURE.md` (строка «Исполнитель»),
|
||
`CLAUDE.md` (границы `Executor`, список тестовых файлов)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 3 — `JiraClient.upload(key, filename, data) -> Any`, `HubClient.file(sha256) -> dict`,
|
||
`wire_filename(name) -> str`; фейки `FakeJira.attachments`, `add_attachment`, `drop_next_post_attachment`,
|
||
`attachment_limit`, `bad_attachment_response`, `FakeHub.files`, `put_file`, `file_requests`.
|
||
- Produces: `FILE_GONE = "файл удалён с хаба, загрузите заново"`;
|
||
`Executor.hub_file(sha: str, size: int) -> Optional[bytes]` (`None` — 404; повреждение и сбой — `HubError`);
|
||
`Executor.do_ensure_attachment(p: dict, cmd: dict) -> dict` — результат `{"attachment_id": str, "existed": True}`
|
||
или `{"attachment_id": str, "created": True}`.
|
||
|
||
- [ ] **Step 1: Темп в тестах**
|
||
|
||
В `tests/_common.py` заменить `ZERO_PACING` на:
|
||
|
||
```python
|
||
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
|
||
"ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0}
|
||
```
|
||
|
||
(Агенту правка не нужна: `ensure_attachment` пишет в Jira один раз и `gap` не вызывает; паузу перед командой задаёт
|
||
`pause_seconds` хаба, а `Executor.gap` для неизвестного типа берёт 0.)
|
||
|
||
- [ ] **Step 2: Падающие тесты исполнителя**
|
||
|
||
Создать `tests/test_executor_attachments.py`:
|
||
|
||
```python
|
||
import hashlib
|
||
import unittest
|
||
|
||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||
from fake_hub import FakeHub
|
||
from fake_jira import FakeJira
|
||
|
||
SHA_ABC = hashlib.sha256(b"abc").hexdigest()
|
||
|
||
|
||
class EnsureAttachmentTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.hub = FakeHub(SETTINGS)
|
||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||
self.jira = agent.JiraClient(cfg)
|
||
self.jira.sleep = no_sleep
|
||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||
self.ex.sleep = no_sleep
|
||
self.fake.add("AURORA-1")
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
self.hub.stop()
|
||
|
||
def run_cmd(self, **params):
|
||
return self.ex.run({"id": 1, "type": "ensure_attachment", "params": params})
|
||
|
||
def test_upload_cyrillic_name(self):
|
||
data = bytes(range(256)) * 8
|
||
sha = self.hub.put_file(data)
|
||
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
|
||
out = self.run_cmd(key="AURORA-1", file=sha, filename=name, size=len(data))
|
||
self.assertEqual(out["status"], "done", out)
|
||
a = self.fake.attachments["AURORA-1"][0]
|
||
self.assertEqual(out["result"], {"attachment_id": a["id"], "created": True})
|
||
self.assertEqual((a["filename"], a["size"], a["data"]), (name, len(data), data))
|
||
self.assertEqual(self.fake.requests, [("GET", "/rest/api/2/issue/AURORA-1"),
|
||
("POST", "/rest/api/2/issue/AURORA-1/attachments")])
|
||
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
|
||
self.assertEqual(self.hub.file_requests, 1)
|
||
self.assertEqual(out["jira_calls"], 2)
|
||
|
||
def test_existing_same_name_and_size(self):
|
||
a = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", b"xyz") # другое содержимое, тот же размер
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="ОСВ 60.xlsx", size=3)
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
|
||
self.assertEqual(out["jira_calls"], 1)
|
||
self.assertEqual(self.hub.file_requests, 0) # файл у хаба не запрашивается
|
||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||
|
||
def test_same_name_other_size_uploads_second(self):
|
||
old = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", "старое содержимое".encode("utf-8"))
|
||
sha = self.hub.put_file(b"abc")
|
||
out = self.run_cmd(key="AURORA-1", file=sha, filename="ОСВ 60.xlsx", size=3)
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertTrue(out["result"]["created"])
|
||
self.assertNotEqual(out["result"]["attachment_id"], old["id"])
|
||
self.assertEqual([x["filename"] for x in self.fake.attachments["AURORA-1"]], ["ОСВ 60.xlsx", "ОСВ 60.xlsx"])
|
||
|
||
def test_existing_under_wire_name(self):
|
||
name = 'итог "кв.2".txt'
|
||
a = self.fake.add_attachment("AURORA-1", agent.wire_filename(name), b"abc") # так Jira сохранила прошлую загрузку
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename=name, size=3)
|
||
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
|
||
self.assertEqual(self.hub.file_requests, 0)
|
||
|
||
def test_size_null_fails_without_calls(self):
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=None)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
|
||
self.assertNotIn("retryable", out)
|
||
self.assertEqual(self.fake.requests, [])
|
||
self.assertEqual(self.hub.file_requests, 0)
|
||
|
||
def test_hub_404_file_gone(self):
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) # в хранилище хаба файла нет
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
|
||
self.assertNotIn("retryable", out)
|
||
self.assertEqual(self.hub.file_requests, 1)
|
||
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
|
||
|
||
def test_corrupted_sha_is_retryable_hub_failure(self):
|
||
self.hub.files[SHA_ABC] = b"xyz" # размер тот же, содержимое другое
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["retryable"])
|
||
self.assertEqual(out["peer"], "хабом")
|
||
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
|
||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
|
||
|
||
def test_length_mismatch_is_retryable_hub_failure(self):
|
||
self.hub.put_file(b"abc")
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=4)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["retryable"])
|
||
self.assertEqual(out["peer"], "хабом")
|
||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||
|
||
def test_hub_non_json_file_no_content_in_error(self):
|
||
def broken(sha):
|
||
raise agent.HttpError(200, 'ответ не JSON: Unterminated string; начало: {"file": "x", "content": "U0VDUkVULUNPTlRFTlQ')
|
||
|
||
self.ex.hub.file = broken
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["retryable"])
|
||
self.assertEqual(out["peer"], "хабом")
|
||
self.assertNotIn("U0VDUkVU", out["error"])
|
||
self.assertNotIn("начало", out["error"])
|
||
|
||
def test_hub_4xx_other_than_404_not_retryable(self):
|
||
self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "wrong-token", "timeout": 5})
|
||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"])
|
||
self.assertFalse(out["retryable"])
|
||
|
||
def test_jira_413_failed_with_code_and_text(self):
|
||
self.fake.attachment_limit = 2
|
||
sha = self.hub.put_file(b"abc")
|
||
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["error"].startswith("HTTP 413"), out["error"])
|
||
self.assertIn("Размер вложения превышает предел", out["error"])
|
||
self.assertFalse(out["retryable"])
|
||
|
||
def test_upload_response_without_id(self):
|
||
self.fake.bad_attachment_response = True
|
||
sha = self.hub.put_file(b"abc")
|
||
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertEqual(out["error"], "Jira не вернула id вложения в ответе на загрузку")
|
||
self.assertNotIn("retryable", out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_attachments -v`
|
||
Expected: FAIL — `failed` «неизвестный тип команды ensure_attachment» во всех тестах.
|
||
|
||
- [ ] **Step 3: Падающие тесты цикла**
|
||
|
||
В `tests/test_runner.py` импорты в начале файла заменить на:
|
||
|
||
```python
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
from datetime import datetime, timedelta, timezone
|
||
```
|
||
|
||
В класс `RunnerTest` после `test_retry_exhausted_on_hub_names_hub` добавить:
|
||
|
||
```python
|
||
def test_attachment_upload_dropped_no_duplicate(self):
|
||
sha = self.hub.put_file(b"abc")
|
||
self.fake.drop_next_post_attachment = True # Jira сохранила вложение, ответ потерян
|
||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "ОСВ 60.xlsx"})
|
||
self.runner.loop()
|
||
r = self.hub.results[1]
|
||
self.assertEqual(r["status"], "done", r)
|
||
self.assertTrue(r["result"]["existed"])
|
||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||
self.assertEqual(r["result"]["attachment_id"], self.fake.attachments["AURORA-1"][0]["id"])
|
||
self.assertEqual(self.hub.file_requests, 1) # повтор нашёл вложение, файл второй раз не нужен
|
||
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5)
|
||
self.assertEqual(r["jira_calls"], 2) # GET + повторный GET (оборванный POST не считается)
|
||
|
||
def test_attachment_corrupted_file_retried_as_hub_drop(self):
|
||
sha = hashlib.sha256(b"abc").hexdigest()
|
||
self.hub.files[sha] = b"xyz" # размер тот же, содержимое другое
|
||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "a.txt"})
|
||
self.runner.loop()
|
||
r = self.hub.results[1]
|
||
self.assertEqual(r["status"], "failed")
|
||
self.assertTrue(r["error"].startswith("результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: "),
|
||
r["error"])
|
||
self.assertEqual(self.hub.file_requests, 4) # первый запуск + 3 повтора
|
||
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
|
||
self.assertEqual(self.fake.attachments, {})
|
||
|
||
def test_attachment_file_gone_at_claim(self):
|
||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": "0" * 64, "filename": "a.txt"})
|
||
self.runner.loop()
|
||
r = self.hub.results[1]
|
||
self.assertEqual((r["status"], r["error"]), ("failed", "файл удалён с хаба, загрузите заново"))
|
||
self.assertEqual(r["jira_calls"], 0)
|
||
self.assertEqual(self.fake.requests, [])
|
||
|
||
def test_attachment_content_not_in_journal_or_log(self):
|
||
data = b"SECRET-CONTENT-" + bytes(range(256)) * 4
|
||
sha = self.hub.put_file(data)
|
||
secret_b64 = base64.b64encode(data).decode("ascii")
|
||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "отчёт.bin"})
|
||
logs = []
|
||
orig = agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
self.runner.loop()
|
||
finally:
|
||
agent.log = orig
|
||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||
with open(self.journal.path, encoding="utf-8") as f:
|
||
text = f.read()
|
||
text += "\n".join(logs) + json.dumps(self.hub.results[1], ensure_ascii=False)
|
||
for needle in ("SECRET-CONTENT", secret_b64[:40], secret_b64[-40:]):
|
||
self.assertNotIn(needle, text)
|
||
self.assertTrue(any(sha in l and "отчёт.bin" in l for l in logs), logs) # file и filename — в логе
|
||
self.assertTrue(any(str(len(data)) in l for l in logs), logs) # размер — в логе
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_runner -v`
|
||
Expected: FAIL — четыре новых теста: отчёт `failed` «неизвестный тип команды ensure_attachment».
|
||
|
||
- [ ] **Step 4: Реализация**
|
||
|
||
В `src/agent.py` после `class HubError` (после его `__init__`) добавить:
|
||
|
||
```python
|
||
FILE_GONE = "файл удалён с хаба, загрузите заново"
|
||
```
|
||
|
||
После метода `do_ensure_sprint` (перед строкой `# --- refresh ---…`) добавить:
|
||
|
||
```python
|
||
# --- ensure_attachment -----------------------------------------------------
|
||
def hub_file(self, sha: str, size: int) -> Optional[bytes]:
|
||
"""Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором,
|
||
как обрыв связи с хабом. Содержимое файла в текст ошибки не попадает."""
|
||
try:
|
||
got = self.hub.file(sha)
|
||
except HttpError as e:
|
||
if e.code == 404:
|
||
return None
|
||
if 200 <= e.code < 300: # ответ не JSON: в тексте Http — начало тела, то есть содержимого
|
||
raise HubError(ConnectionError("ответ на файл %s не JSON" % sha)) from None
|
||
raise HubError(e) from None
|
||
except ConnectionError as e:
|
||
raise HubError(e) from None
|
||
content = got.get("content") if isinstance(got, dict) else None
|
||
data = None
|
||
if isinstance(content, str):
|
||
try:
|
||
data = base64.b64decode(content, validate=True)
|
||
except ValueError: # binascii.Error — подкласс ValueError
|
||
data = None
|
||
if data is None or len(data) != size or hashlib.sha256(data).hexdigest() != sha:
|
||
raise HubError(ConnectionError("файл %s пришёл повреждённым: sha256 или размер не сходятся" % sha))
|
||
return data
|
||
|
||
def do_ensure_attachment(self, p: dict, cmd: dict) -> dict:
|
||
key, sha, filename, size = p["key"], p["file"], p["filename"], p.get("size")
|
||
if size is None: # хаб не нашёл файл при выдаче
|
||
return self._out("failed", error=FILE_GONE)
|
||
names = {filename, wire_filename(filename)} # Jira могла сохранить имя в «браузерной» форме (R-23)
|
||
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
|
||
for a in (issue.get("fields") or {}).get("attachment") or []:
|
||
if a.get("filename") in names and a.get("size") == size:
|
||
return self._out("done", {"attachment_id": str(a.get("id")), "existed": True})
|
||
data = self.hub_file(sha, size)
|
||
if data is None:
|
||
return self._out("failed", error=FILE_GONE)
|
||
log(" вложение «%s» (%d байт) → %s" % (filename, size, key))
|
||
created = self.jira.upload(key, filename, data)
|
||
first = created[0] if isinstance(created, list) and created and isinstance(created[0], dict) else {}
|
||
if not first.get("id"):
|
||
return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку")
|
||
return self._out("done", {"attachment_id": str(first["id"]), "created": True})
|
||
```
|
||
|
||
- [ ] **Step 5: Тесты исполнителя и цикла проходят**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_attachments tests.test_runner -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Документация**
|
||
|
||
1. `README.md`, таблица «Что делает агент с командами»: после строки `ensure_sprint` добавить:
|
||
|
||
```markdown
|
||
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
||
```
|
||
|
||
2. `ARCHITECTURE.md`, строку таблицы `| Исполнитель | …` заменить на:
|
||
|
||
```markdown
|
||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
||
```
|
||
|
||
3. `CLAUDE.md`, раздел «Архитектура»: строку
|
||
|
||
```markdown
|
||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
|
||
```
|
||
|
||
заменить на
|
||
|
||
```markdown
|
||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения).
|
||
```
|
||
|
||
4. `CLAUDE.md`, раздел «Тесты»: в перечне файлов после `` `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), ``
|
||
добавить `` `test_executor_attachments.py` (`ensure_attachment`), ``.
|
||
|
||
- [ ] **Step 7: Весь набор**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/agent.py tests/_common.py tests/test_executor_attachments.py tests/test_runner.py README.md ARCHITECTURE.md CLAUDE.md
|
||
git commit -m "feat(agent): команда ensure_attachment — вложение по имени и размеру, файл у хаба с проверкой sha256 (#31)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Версия 11, документация, change решений агента
|
||
|
||
**Модель исполнителя:** haiku (готовый текст).
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py:36` (`AGENT_VERSION`)
|
||
- Modify: `tests/test_folder.py:160-161` (`test_version_is_9`)
|
||
- Modify: `README.md` (пункт «Версия» раздела «Что делает агент с командами»)
|
||
- Modify: `CLAUDE.md` (раздел «Первая подсистема» — последний change)
|
||
- Create: `openspec/changes/agent-v11-bug-attachments/design.md` (через `openspec new change agent-v11-bug-attachments`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1–4.
|
||
- Produces: `AGENT_VERSION = 11`.
|
||
|
||
- [ ] **Step 1: Падающий тест версии**
|
||
|
||
В `tests/test_folder.py` метод
|
||
|
||
```python
|
||
def test_version_is_9(self):
|
||
self.assertEqual(agent.AGENT_VERSION, 10)
|
||
```
|
||
|
||
заменить на
|
||
|
||
```python
|
||
def test_version(self):
|
||
self.assertEqual(agent.AGENT_VERSION, 11)
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||
Expected: FAIL — `AssertionError: 10 != 11`.
|
||
|
||
- [ ] **Step 2: Версия**
|
||
|
||
В `src/agent.py` строку `AGENT_VERSION = 10` заменить на `AGENT_VERSION = 11`.
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 3: README — версия**
|
||
|
||
В `README.md`, пункт `- **Версия.**` раздела «Что делает агент с командами»: `(сейчас 9)` заменить на `(сейчас 11)`, а
|
||
после предложения `` …источник ошибки хаба в `refresh` (номера 6–8 заняты живой проверкой самообновления). `` дописать
|
||
строку того же отступа (2 пробела):
|
||
|
||
```markdown
|
||
Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации
|
||
(тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30).
|
||
Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`.
|
||
```
|
||
|
||
- [ ] **Step 4: CLAUDE.md**
|
||
|
||
В `CLAUDE.md`, раздел «Первая подсистема», заменить строку, начинающуюся с `Последний change —`, на:
|
||
|
||
```markdown
|
||
Последний change — `../jira-hub/openspec/changes/bug-and-attachments/` (агент v11: тип «Ошибка», `ensure_attachment`, устойчивое дочитывание комментариев; решения агента — `openspec/changes/agent-v11-bug-attachments/design.md`), план — `docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md`.
|
||
```
|
||
|
||
- [ ] **Step 5: Change решений агента**
|
||
|
||
Run: `openspec new change agent-v11-bug-attachments`
|
||
Expected: создан каталог `openspec/changes/agent-v11-bug-attachments/`.
|
||
|
||
Создать `openspec/changes/agent-v11-bug-attachments/design.md` (таблица — те же строки R-20…R-26, что в разделе
|
||
«Decisions made without discussion» этого плана; решения, принятые при исполнении, контроллер дописывает следующими
|
||
номерами, начиная с R-27):
|
||
|
||
```markdown
|
||
# agent-v11-bug-attachments — решения агента
|
||
|
||
Спека и дизайн изменения — в `jira-hub/openspec/changes/bug-and-attachments/` (issue aep/jira-hub#43, aep/jira-agent#31).
|
||
Агентская часть — раздел «Агент v11» дизайна хаба и требования `agent` «Тип задачи и вложения» (ADDED) и
|
||
«Чтение состояния» (MODIFIED: M-1, M-2 из aep/jira-agent#30).
|
||
|
||
Решения владельца 15.10.2026 (brainstorming jira-hub#43): файл приходит только из MCP; обязательные поля «Ошибки» — как у
|
||
«Задачи»; файл хранится в хабе, агент забирает его отдельным запросом `GET /agent/files/{sha256}` (base64 в JSON).
|
||
|
||
## Decisions made without discussion
|
||
|
||
Решения 1–8 записаны в `jira-hub/openspec/changes/bug-and-attachments/design.md`
|
||
(раздел «Decisions made without discussion»); здесь — только решения агентской части.
|
||
|
||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||
|---|---|---|---|---|
|
||
| R-20 | 2026-10-15 | Любой 404 от `GET /agent/files/{sha}` — `failed` «файл удалён с хаба, загрузите заново», без разбора кода `no_file` в теле | Спека hub-api: у маршрута 404 бывает только `no_file`; агент v11 публикуется после выкатки хаба с маршрутом (design «Выкатка», шаги 1 и 3) | Если хаб без маршрута (откат хаба) — вводящий в заблуждение текст «загрузите заново» вместо «нет маршрута»; повторная загрузка не поможет, причина видна по логу хаба |
|
||
| R-21 | 2026-10-15 | Таймаут `GET /agent/files` — обычный `timeout` агента (60 с), не поднимается до 120 с, как у загрузки в Jira | design Risks: круг папки для файла 10 МБ (≈13,4 МБ JSON) — секунды, «таймаут вызова агента 60 с с запасом»; таймаут — `ConnectionError` → повтор как обрыв связи с хабом | На медленной папке большой файл не успеет за 60 с: 4 попытки и `failed` «результат неизвестен»; лечится `timeout` в `agent.json` |
|
||
| R-22 | 2026-10-15 | Ответ хаба на файл с кодом 2xx, но не JSON — временный сбой («ответ на файл <sha> не JSON») без текста `HttpError` | Текст `Http` содержит начало тела, то есть base64 содержимого, а спека запрещает содержимое в журнале и логе; обрезанный ответ — сбой пути, как повреждение | Диагностика беднее: начала тела в ошибке нет, причину искать по логу хаба/реле |
|
||
| R-23 | 2026-10-15 | В `filename="…"` кавычка и переводы строк кодируются как у браузеров (`%22`, `%0D`, `%0A`); при поиске уже приложенного вложения сравниваются обе формы имени — исходная и «браузерная» | Хаб запрещает в имени только `/` и `\`; сырая кавычка ломает заголовок части; если Jira сохранит имя в закодированной форме, без второй формы каждый повтор команды создавал бы дубль | Вложение с такими символами в Jira может называться `…%22…`; другое вложение, чьё имя совпало с «браузерной» формой, будет принято за уже приложенное |
|
||
| R-24 | 2026-10-15 | Ответ Jira на загрузку не список или без `id` у первого элемента — `failed` «Jira не вернула id вложения в ответе на загрузку», без повтора | Спека: результат — `attachment_id` из ответа; повтор команды человеком найдёт вложение по имени и размеру и вернёт `done` с id | Если Jira на самом деле не сохранила вложение, `failed` без повтора требует ручного повтора команды |
|
||
| R-25 | 2026-10-15 | Загрузка вложения идёт через тот же повтор 429/503 по `Retry-After`, что и остальные вызовы Jira (`JiraClient._retry`) | 429/503 — запрос не обработан, повтор безопасен; спека «Ограничение темпа и 429» не делает исключений для записи | 503 от прокси после фактической записи даст второе вложение; вероятность низкая, лишнее удаляется руками |
|
||
| R-26 | 2026-10-15 | Тип задачи проверяется до поиска дубля в `ensure_issue`: без настройки типа — ни одного вызова Jira, даже если задача с той же темой уже есть | Спека: «`failed` без вызова Jira»; настройку могли убрать между постановкой и выдачей (design решение 6) | Повтор `ensure_issue` по уже созданной задаче после удаления настройки типа даёт `failed` вместо `done` с найденным ключом |
|
||
```
|
||
|
||
- [ ] **Step 6: Весь набор**
|
||
|
||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||
Expected: `OK` (до плана — 230 тестов; после — 230 + новые из Task 1–4).
|
||
|
||
- [ ] **Step 7: Проверка синтаксиса 3.9 и самопроверка CLI**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_py39 -v && /usr/bin/python3 -c "import ast,sys; ast.parse(open('src/agent.py',encoding='utf-8').read()); print('ok')"`
|
||
Expected: PASS и `ok`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/agent.py tests/test_folder.py README.md CLAUDE.md openspec/changes/agent-v11-bug-attachments
|
||
git commit -m "chore(agent): версия 11, README и решения агента для bug-and-attachments (#31)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Decisions made without discussion
|
||
|
||
Нумерация рулингов агентской части — с R-20, чтобы не пересечься с рулингами исполнения хабовой части change.
|
||
|
||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||
|---|---|---|---|---|
|
||
| R-20 | 2026-10-15 | Любой 404 от `GET /agent/files/{sha}` — `failed` «файл удалён с хаба, загрузите заново», без разбора кода `no_file` в теле | Спека hub-api: у маршрута 404 бывает только `no_file`; агент v11 публикуется после выкатки хаба с маршрутом (design «Выкатка», шаги 1 и 3) | Если хаб без маршрута (откат хаба) — вводящий в заблуждение текст «загрузите заново» вместо «нет маршрута»; повторная загрузка не поможет, причина видна по логу хаба |
|
||
| R-21 | 2026-10-15 | Таймаут `GET /agent/files` — обычный `timeout` агента (60 с), не поднимается до 120 с, как у загрузки в Jira | design Risks: круг папки для файла 10 МБ (≈13,4 МБ JSON) — секунды, «таймаут вызова агента 60 с с запасом»; таймаут — `ConnectionError` → повтор как обрыв связи с хабом | На медленной папке большой файл не успеет за 60 с: 4 попытки и `failed` «результат неизвестен»; лечится `timeout` в `agent.json` |
|
||
| R-22 | 2026-10-15 | Ответ хаба на файл с кодом 2xx, но не JSON — временный сбой («ответ на файл <sha> не JSON») без текста `HttpError` | Текст `Http` содержит начало тела, то есть base64 содержимого, а спека запрещает содержимое в журнале и логе; обрезанный ответ — сбой пути, как повреждение | Диагностика беднее: начала тела в ошибке нет, причину искать по логу хаба/реле |
|
||
| R-23 | 2026-10-15 | В `filename="…"` кавычка и переводы строк кодируются как у браузеров (`%22`, `%0D`, `%0A`); при поиске уже приложенного вложения сравниваются обе формы имени — исходная и «браузерная» | Хаб запрещает в имени только `/` и `\`; сырая кавычка ломает заголовок части; если Jira сохранит имя в закодированной форме, без второй формы каждый повтор команды создавал бы дубль | Вложение с такими символами в Jira может называться `…%22…`; другое вложение, чьё имя совпало с «браузерной» формой, будет принято за уже приложенное |
|
||
| R-24 | 2026-10-15 | Ответ Jira на загрузку не список или без `id` у первого элемента — `failed` «Jira не вернула id вложения в ответе на загрузку», без повтора | Спека: результат — `attachment_id` из ответа; повтор команды человеком найдёт вложение по имени и размеру и вернёт `done` с id | Если Jira на самом деле не сохранила вложение, `failed` без повтора требует ручного повтора команды |
|
||
| R-25 | 2026-10-15 | Загрузка вложения идёт через тот же повтор 429/503 по `Retry-After`, что и остальные вызовы Jira (`JiraClient._retry`) | 429/503 — запрос не обработан, повтор безопасен; спека «Ограничение темпа и 429» не делает исключений для записи | 503 от прокси после фактической записи даст второе вложение; вероятность низкая, лишнее удаляется руками |
|
||
| R-26 | 2026-10-15 | Тип задачи проверяется до поиска дубля в `ensure_issue`: без настройки типа — ни одного вызова Jira, даже если задача с той же темой уже есть | Спека: «`failed` без вызова Jira»; настройку могли убрать между постановкой и выдачей (design решение 6) | Повтор `ensure_issue` по уже созданной задаче после удаления настройки типа даёт `failed` вместо `done` с найденным ключом |
|
||
|
||
## После плана
|
||
|
||
- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #31 и на change `bug-and-attachments`
|
||
(merge — после PR хаба, design «Выкатка»).
|
||
- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на
|
||
main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status`
|
||
«обновление: ok v10 → v11»; затем `min_agent_version = 11` и живая проверка (design «Выкатка», шаги 3–4: «Ошибка»
|
||
в бэклог + вложение с кириллическим именем, повтор пакета без дублей).
|