Пакет D хаба: публикация агента, статус, настройки, тесты без утечек (#32) #36
@ -15,7 +15,7 @@
|
|||||||
| `backup` | копии файла базы, ротация | `paths` |
|
| `backup` | копии файла базы, ротация | `paths` |
|
||||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
|
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
|
||||||
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
|
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
|
||||||
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` |
|
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
|
||||||
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
||||||
| `mcp_server` | инструменты MCP по stdio | `client` |
|
| `mcp_server` | инструменты MCP по stdio | `client` |
|
||||||
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
|
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
|
||||||
@ -34,7 +34,7 @@
|
|||||||
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
|
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
|
||||||
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
|
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
|
||||||
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
|
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
|
||||||
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`.
|
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
|
||||||
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
|
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
@ -48,4 +48,3 @@
|
|||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
|
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
|
||||||
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
|
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
|
||||||
- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`).
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
## Тесты
|
## Тесты
|
||||||
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
|
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
|
||||||
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
|
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
|
||||||
|
- Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора.
|
||||||
- Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе.
|
- Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе.
|
||||||
|
|
||||||
## Первая подсистема
|
## Первая подсистема
|
||||||
|
|||||||
13
README.md
13
README.md
@ -116,6 +116,15 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
|||||||
запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером.
|
запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером.
|
||||||
Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.
|
Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.
|
||||||
|
|
||||||
|
Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и
|
||||||
|
выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб
|
||||||
|
не может разобрать (`version` не целое, `sha256` не 64 символа `[0-9a-f]`), означает «публикации нет» — одно предупреждение в
|
||||||
|
логе хаба на каждую новую ошибку.
|
||||||
|
|
||||||
|
Состояние обновления и версию запускателя агент сообщает в каждом `claim`, хаб держит их в памяти: сразу после
|
||||||
|
перезапуска хаба, пока агент не вышел на связь (до первого `claim` агента, обычно до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не
|
||||||
|
выходил на связь после перезапуска хаба)», а подсказки «агент запущен без run.py» нет.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
|
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
|
||||||
@ -139,8 +148,8 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
|||||||
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
|
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
|
||||||
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
|
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
|
||||||
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
|
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
|
||||||
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `update_hint`), журнал загрузок |
|
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок |
|
||||||
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) |
|
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')
|
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')
|
||||||
|
|||||||
1751
docs/superpowers/plans/2026-10-15-hub-hygiene-d.md
Normal file
1751
docs/superpowers/plans/2026-10-15-hub-hygiene-d.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/hub-hygiene-d/.openspec.yaml
Normal file
2
openspec/changes/hub-hygiene-d/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-15
|
||||||
116
openspec/changes/hub-hygiene-d/design.md
Normal file
116
openspec/changes/hub-hygiene-d/design.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Хаб `main` 8229bba (web-app и agent-v9-hardening слиты). Issue aep/jira-hub#32. Источники: #25 (M-1…M-3, M-5…M-7),
|
||||||
|
#16 (T3.1, T3.2, T5.2, M-8, ре-ревью minor-1, minor-3, O-9, O-10, O-11), #4 (M-9/O-4, M-10). Сверка с кодом — 15.10.2026.
|
||||||
|
|
||||||
|
Найдено при чтении кода:
|
||||||
|
- `release._write_atomic` — `tmp.write_bytes` + `os.replace`, без `fsync` и без очистки `.tmp` при ошибке; `publish`
|
||||||
|
пишет `agent.py`, затем манифест. `cli` ловит только `PublishError`. Если манифест не записался, новый `agent.py`
|
||||||
|
не сходится со старым манифестом по sha → `load` → `None` → 404.
|
||||||
|
- `release.current`: `int(m["version"])` (6.9 → 6, `true` → 1), `str(m["sha256"])`; `log.warning` на каждый вызов
|
||||||
|
(каждый `claim` и `GET /agent`).
|
||||||
|
- `release.load`: `data.decode("utf-8")` после сверки sha, `UnicodeDecodeError` не ловится → 500.
|
||||||
|
- `Hub.agent_update = {"update_state": None, "launcher_version": None}` — в памяти; после перезапуска хаба до первого
|
||||||
|
`claim` `launcher_version = None` неотличимо от «агент без запускателя» (`release.hint`, `mcp_server.fmt_agent:115`).
|
||||||
|
- `h_settings_put` отвечает `{"settings_version": version}`.
|
||||||
|
- `queue._next`: при неразрешённом временном ключе — `skipped` «задача new:N не создана», независимо от того,
|
||||||
|
`failed` или `cancelled` команда-создатель (`_definer_status`).
|
||||||
|
- `commands.check_confirmed`: `(issue.get(field) or None) != (p[field] or None)` — `\r\n` в зеркале против `\n` в
|
||||||
|
параметре даёт «не совпало».
|
||||||
|
- `relay._cleaned: set[Path]`, проверка `self.root not in self._cleaned` — по написанию пути.
|
||||||
|
- Тесты: 249 passed; 88 `ResourceWarning: unclosed database` (test_queue 37, test_commands 18, test_mirror 15, test_db 6, test_importer 6, test_settings 6; в test_relay их нет).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** публикация не оставляет хаб без годной версии при сбое записи и не роняет команду трейсбеком; статус агента
|
||||||
|
не утверждает неизвестное; подтверждение по состоянию работает на задачах с CRLF; ответы и тексты называют то, что
|
||||||
|
произошло; тесты закрывают найденные пробелы и чисты от `ResourceWarning`.
|
||||||
|
|
||||||
|
**Non-Goals:** см. proposal.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Публикация агента
|
||||||
|
|
||||||
|
1. **Запись файла** (`_write_atomic`): временный файл `.<имя>.tmp` в том же каталоге, `write` + `flush` + `os.fsync`,
|
||||||
|
`os.replace`; при `OSError` временный файл удаляется (ошибки удаления игнорируются), исключение пробрасывается.
|
||||||
|
2. **Порядок публикации:** `agent.py` → `agent.py.prev` (`os.replace`, если `agent.py` есть); новый файл через
|
||||||
|
`_write_atomic` в `agent.py`; манифест через `_write_atomic`. `OSError` после переименования — `agent.py.prev`
|
||||||
|
возвращается на место `agent.py` (`os.replace`; если и это не удалось — в лог), и `PublishError("не удалось записать
|
||||||
|
публикацию: <ошибка>")`. После успешной записи манифеста `agent.py.prev` удаляется. Итог: при любом сбое опубликована
|
||||||
|
прежняя версия (манифест и `agent.py` согласованы), `release/` без временных файлов.
|
||||||
|
3. **`cli`** печатает текст `PublishError` в stderr и возвращает 1 — как сейчас; новых веток нет, `OSError` до `cli` не
|
||||||
|
доходит.
|
||||||
|
4. **Годный манифест** (`current`): объект; `version` — `int` и не `bool`, ≥ 1; `sha256` — строка, `re.fullmatch(r"[0-9a-f]{64}")`.
|
||||||
|
Иначе — «публикации нет». Совпадает с проверкой агента v9 (design agent-v9-hardening 10).
|
||||||
|
5. **Предупреждение о битом манифесте** — один раз на каждый новый текст ошибки: модульная переменная с последним
|
||||||
|
текстом; успешное чтение или отсутствие манифеста её сбрасывает. Отсутствие манифеста — не ошибка, без лога.
|
||||||
|
6. **`load`**: `UnicodeDecodeError` → `log.error` «agent.py не в UTF-8 — публикации нет» и `None` (404 `no_release`).
|
||||||
|
|
||||||
|
### Статус агента
|
||||||
|
|
||||||
|
7. **`agent_reported`**: `Hub.agent_update` получает ключ `reported: False`; `note_agent_update` (вызывается на каждом
|
||||||
|
`claim`) ставит `True`. `GET /agent` отдаёт `agent_reported`. `release.hint(...)` получает признак и не выдаёт
|
||||||
|
«агент запущен без run.py…», пока `agent_reported = false`; подсказки об откате и отказе — по `update_state` как
|
||||||
|
сейчас (до первого `claim` он `null`).
|
||||||
|
8. **MCP `agent_status`**: строка запускателя — «запускатель: v<N>», «без запускателя» (агент сообщил `null`) или
|
||||||
|
«запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (`agent_reported = false`).
|
||||||
|
Если поля `agent_reported` в ответе нет (старый хаб) — прежнее поведение.
|
||||||
|
|
||||||
|
### Настройки, очередь, подтверждение, реле
|
||||||
|
|
||||||
|
9. **`PUT /settings`** отвечает `settings.get_all(conn)` (токены скрыты) после записи — те же поля, что `GET /settings`,
|
||||||
|
включая `settings_version`. Клиенты, читающие `settings_version` из ответа (`client`, `mcp_server.settings_set`,
|
||||||
|
web), не меняются.
|
||||||
|
10. **Пропуск зависимой команды**: если среди неразрешённых ключей есть ключ, чья `ensure_issue` в `cancelled`, текст —
|
||||||
|
«создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана».
|
||||||
|
11. **Сравнение строк в `check_confirmed`**: для полей `ensure_fields` со строковыми значениями сравнение после замены
|
||||||
|
`\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. `\r\n` нормализуется во всех строковых полях `ensure_fields`. Метки и прочие поля — без изменений.
|
||||||
|
12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) —
|
||||||
|
сравнение по исходному пути, как сейчас.
|
||||||
|
|
||||||
|
### Тесты и чистота
|
||||||
|
|
||||||
|
13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится
|
||||||
|
при `failed` не-refresh по пределу выдач (`test_queue`; проверка T5.2 буквально невозможна, тест проверяет соседний эффект, как в плане); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без
|
||||||
|
«Ключ» (`test_importer`); «подготовлена заново» в `test_vanished_tree_keeps_work` (`test_relay`); предупреждение
|
||||||
|
«обновите агента» при `agent_version < min_agent_version`, офлайн-агент (нет контакта дольше порога) в
|
||||||
|
`GET /agent`, `tick` — проверка эффекта (`test_api`/`test_queue`); публикация — сбой записи манифеста возвращает
|
||||||
|
прежний `agent.py`, `.tmp` нет; битый манифест (`6.9`, `true`, короткий sha) — «публикации нет», предупреждение один
|
||||||
|
раз; не-UTF-8 → 404; `agent_reported` до и после `claim`; `PUT /settings` отдаёт `relay_dir`; текст «отменено»;
|
||||||
|
CRLF-подтверждение; реле с симлинком на ту же папку.
|
||||||
|
14. **`ResourceWarning`**: фикстуры и хелперы тестов закрывают соединения (`conn.close()` / `contextlib.closing`);
|
||||||
|
критерий — `uv run pytest -W error::ResourceWarning` без предупреждений `unclosed database` (pytest
|
||||||
|
`filterwarnings = error::ResourceWarning` в `pyproject.toml`, если не ломает сторонние пакеты; иначе — проверка в
|
||||||
|
плане командой).
|
||||||
|
15. **T3.1**: `_type_error` переписывается без тяжёлой строки с `and` и кортежем — отдельные ветки по типу.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [`agent.py.prev` в `release/`] лишний файл живёт только между переименованием и записью манифеста; при падении
|
||||||
|
процесса посреди — остаётся; следующая публикация его перезапишет. → при падении процесса между переименованием и записью `agent.py` нет вовсе, и `GET /agent/release` отдаёт 404 до следующей публикации (как и прежде при падении между записью agent.py и манифеста).
|
||||||
|
- [«неизвестно» до первого claim] до первого `claim` агента (обычно до 30 с) после перезапуска хаба статус неполный. → честнее ложного «без запускателя».
|
||||||
|
- [`PUT /settings` отдаёт больше полей] клиенты, ожидавшие ровно `{settings_version}`, получают надмножество. → поле на
|
||||||
|
месте.
|
||||||
|
- [Конфликт с #29] текстовый в `tests/test_api.py` (одна строка у #29). → кто сливается вторым, разрешает.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| R-1 (design #7) | 2026-10-15 | Признак `agent_reported` в памяти, а не `launcher_version` в БД | без записи на каждый claim; значение из БД после перезапуска могло устареть | до 30 с «неизвестно» после перезапуска хаба |
|
||||||
|
| R-2 (design #2) | 2026-10-15 | Откат публикации через `agent.py.prev` (переименования, без копии) | переименование не требует места на диске — откат работает и при полном диске | лишний файл в `release/` при падении процесса посреди публикации |
|
||||||
|
| R-3 (design #9) | 2026-10-15 | `PUT /settings` отвечает всеми настройками, а не `{settings_version, relay_dir}` | один формат с `GET /settings`; O-11 — «не видно, что записалось» относится к любой настройке | надмножество полей в ответе |
|
||||||
|
| R-4 (design #10) | 2026-10-15 | «Отменено» — только при `cancelled` создателе; при `failed` текст прежний | O-10 описывает только force-отмену; `failed` — задача действительно не создана | нет |
|
||||||
|
| R-5 (design #14) | 2026-10-15 | Цель — ноль `ResourceWarning` от sqlite в тестах, проверка `-W error::ResourceWarning` | M-9/O-4: 87 предупреждений прячут настоящие утечки | время на правку фикстур |
|
||||||
|
| R-6 (design #14) | 2026-10-15 | В `filterwarnings` два фильтра: `error::ResourceWarning` и `error::pytest.PytestUnraisableExceptionWarning` | утечка соединения рождается в `__del__`, одного ResourceWarning pytest не ловит | утечка останется незамеченной |
|
||||||
|
| R-7 (design #14) | 2026-10-15 | Соединения закрываются фикстурами/хелперами (conftest `open_db`), а не autouse-подменой `db.connect` | подмена спрятала бы будущие утечки в `src/` | больше правок в тестах |
|
||||||
|
| R-8 (design #2) | 2026-10-15 | При сбое самой первой публикации (прежнего `agent.py` нет) новый `agent.py` удаляется, `release/` остаётся без публикации | `agent.py` без манифеста даёт 404 так же, но оставляет мусор | пустой `release/` вместо непарного файла |
|
||||||
|
| R-9 (design #2) | 2026-10-15 | `agent.py.prev` удаляется после любой успешной публикации | файл нужен только для отката | нет |
|
||||||
|
| R-10 (design #2) | 2026-10-15 | `secure_dir` при публикации вызывается внутри `try` | его `OSError` тоже должен стать `PublishError`, а не трейсбеком | трейсбек в CLI |
|
||||||
|
| R-11 (design #7) | 2026-10-15 | У `release.hint(..., reported=True)` значение по умолчанию | не переписывать старые вызовы и тесты | забытый вызов без признака даёт прежнее поведение |
|
||||||
|
| R-12 (design #5) | 2026-10-15 | Запоминание последнего текста ошибки манифеста (`_warned`) под `threading.Lock` | `current` вызывается из потоков HTTP-сервера | двойное предупреждение при гонке |
|
||||||
|
| R-13 (design #14) | 2026-10-15 | Первый абзац `CLAUDE.md` («текущий change — …») не менять | его правит параллельная ветка #29 (`feat/29-web-app-followups`), конфликт гарантирован; строка перепишется при архивации | до архивации `CLAUDE.md` в ветке называет чужой change. Итог: после слияния #29 первый абзац `CLAUDE.md` в main общий («активные change — `openspec/changes/` (кроме `archive/`)»), поэтому при архивации его переписывать не нужно |
|
||||||
|
| R-14 (merge) | 2026-10-15 | Контроллер main в ветку не сливал; после слияния #29 (main 55886bd) сессия влила main в ветку до PR, конфликт `pyproject.toml` (блок `[tool.uv]` из #29 против `filterwarnings`) разрешён: оставлены обе секции | PR без конфликта, Gitea не разрешает конфликты в интерфейсе; после слияния 318 passed, 0 warnings | merge-коммит в истории ветки |
|
||||||
|
| R-15 (design #2) | 2026-10-15 | T2-I1 (откат удалял прежний `agent.py` при сбое до переименования в `.prev`) исправлен в рамках задачи 2: `existed = agent.exists()` до `try`, новый `agent.py` удаляется, только если прежнего не было | потеря опубликованной версии недопустима даже между задачами | нет |
|
||||||
|
| R-16 (план) | 2026-10-15 | Дешёвые minor ревьюеров задач и финального ревью (T1.1, T1.2, T2.3, T4.1, M-1…M-5) исправлены одной волной перед PR, а не отложены | правки малы и в рамках спеки | чуть больше diff в PR |
|
||||||
57
openspec/changes/hub-hygiene-d/proposal.md
Normal file
57
openspec/changes/hub-hygiene-d/proposal.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
У хаба накопились отложенные находки трёх change'ей: самообновления (#25), пакета C (#16) и первого change'а (#4,
|
||||||
|
«на потом»). Разбор против `main` 8229bba (15.10.2026) показал, что актуальны все пункты, кроме M-4 #25, который исправило
|
||||||
|
слияние web-app. Вред у одного пользователя небольшой, но заметный:
|
||||||
|
- при полном диске `jira-hub agent publish` падает с трейсбеком и оставляет публикацию в состоянии «404 до следующей
|
||||||
|
публикации»;
|
||||||
|
- после перезапуска хаба `agent_status` пишет «без запускателя», хотя агент под `run.py`;
|
||||||
|
- потерянный отчёт `ensure_fields` по задаче, у которой описание с `\r\n` (71 из 180 правлены в UI), закрывается
|
||||||
|
довыдачей вместо подтверждения по состоянию;
|
||||||
|
- в тестах 87 незакрытых соединений sqlite и пробелы в покрытии.
|
||||||
|
|
||||||
|
Решения владельца 15.10.2026: всё актуальное — одним пакетом; M-4 #25 закрыть, minor-2 #16 отклонить; агентский
|
||||||
|
#26 в пакет не входит; работать параллельно с #29 (web-app follow-ups), она трогает только `h_app`.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Публикация агента** (`release.py`, `cli.py`): запись с `fsync`; прежний `agent.py` переименовывается в
|
||||||
|
`agent.py.prev`, при сбое записи манифеста возвращается; `OSError` → «не удалось записать публикацию: <ошибка>»,
|
||||||
|
код 1, временные файлы удалены; манифест годен, только если `version` — целое (не bool) и `sha256` — 64 hex-символа;
|
||||||
|
не-UTF-8 файл при совпавшем sha — 404, а не 500; предупреждение о битом манифесте — один раз на каждую новую ошибку.
|
||||||
|
- **`GET /agent`**: поле `agent_reported` (был ли `claim` с момента запуска хаба); пока его нет — подсказки про `run.py`
|
||||||
|
нет; MCP `agent_status` пишет «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)».
|
||||||
|
- **`PUT /settings`**: ответ — все настройки, как `GET /settings` (включая `settings_version`).
|
||||||
|
- **Очередь**: зависимая команда, пропущенная из-за force-отменённой `ensure_issue`, получает
|
||||||
|
«создание задачи new:N отменено».
|
||||||
|
- **Подтверждение по состоянию**: строки `ensure_fields` сравниваются без различия `\r\n` и `\n`.
|
||||||
|
- **Реле**: обработанные папки сравниваются по `resolve()`.
|
||||||
|
- **Тесты**: bool в строковом параметре и пустой список; `finished_at` у `sync_run` только для `refresh`; 501 на прочие
|
||||||
|
методы; `epics.csv` без ключа; «подготовлена заново» в реле; предупреждение `min_agent_version`; офлайн-агент; `tick` с
|
||||||
|
проверками; ноль `ResourceWarning`; читаемый `commands._type_error`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
<!-- нет -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `hub-api`: ответ `PUT /settings`; публикация версии агента (сбой записи, проверка манифеста, `agent_reported`).
|
||||||
|
- `mcp`: строка запускателя в `agent_status`.
|
||||||
|
- `commands`: текст `skipped` при отменённом создании; подтверждение `ensure_fields` без различия перевода строки.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- сохранение `launcher_version` в БД (запись на каждый claim; «неизвестно» честнее устаревшего значения);
|
||||||
|
- агент (aep/jira-agent#26) — в следующий change агента;
|
||||||
|
- minor-2 #16 (дубль предупреждения при неудачном `chmod`) — отклонено;
|
||||||
|
- раздача веб-приложения (`h_app`) — в #29.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- jira-hub (issue aep/jira-hub#32): `src/jira_hub/release.py`, `cli.py`, `api.py` (`Hub`, `h_agent_get`,
|
||||||
|
`h_settings_put`), `queue.py` (`_next`), `commands.py` (`check_confirmed`, `_type_error`), `relay.py` (`_cleaned`),
|
||||||
|
`mcp_server.py` (`fmt_agent`), `tests/`, ARCHITECTURE.md при необходимости.
|
||||||
|
- Пересечение с `feat/29-web-app-followups`: `api.py` (другие функции) и одна строка `tests/test_api.py`; дельты спек
|
||||||
|
трогают разные требования (`hub-api` «Раздача веб-приложения» у #29).
|
||||||
|
- Выкатка: после merge — pull на Mac mini, перезапуск хаба (владелец), `/mcp` → reconnect в сессиях Claude.
|
||||||
62
openspec/changes/hub-hygiene-d/specs/commands/spec.md
Normal file
62
openspec/changes/hub-hygiene-d/specs/commands/spec.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Временные ключи и ссылки внутри пакета
|
||||||
|
Параметр-ключ любой команды MAY быть временным `new:<n>`. Хаб SHALL выдавать агенту такую команду только после `done`
|
||||||
|
команды `ensure_issue` с тем же `temp_key` в том же пакете, подставив настоящий ключ. Если `ensure_issue` завершилась
|
||||||
|
`failed` или `cancelled`, зависимые команды SHALL получить `skipped` с ошибкой «создание задачи new:<n> отменено», если
|
||||||
|
`ensure_issue` отменена, иначе «задача new:<n> не создана». Временный ключ вне пакета с его `ensure_issue` SHALL
|
||||||
|
отвергаться при создании пакета (422).
|
||||||
|
|
||||||
|
#### Scenario: Создать и привязать
|
||||||
|
- **WHEN** пакет: `ensure_issue {temp_key: new:60, …}`, `ensure_link {source: new:60, target: AURORA-1537, …}`
|
||||||
|
- **THEN** вторая команда выдаётся агенту с `source = AURORA-1590` после завершения первой
|
||||||
|
|
||||||
|
#### Scenario: Создание отменено
|
||||||
|
- **WHEN** пакет: `ensure_issue {temp_key: new:901, …}` (отменена с `force: true`), `ensure_comment {key: new:901, …}`
|
||||||
|
- **THEN** `ensure_comment` — `skipped` с ошибкой «создание задачи new:901 отменено»
|
||||||
|
|
||||||
|
#### Scenario: Создание упало
|
||||||
|
- **WHEN** `ensure_issue {temp_key: new:902, …}` завершилась `failed`, зависимая `ensure_link {source: new:902, …}`
|
||||||
|
- **THEN** `ensure_link` — `skipped` с ошибкой «задача new:902 не создана»
|
||||||
|
|
||||||
|
### Requirement: Ошибки и подтверждение по состоянию
|
||||||
|
Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`,
|
||||||
|
обрезая присланное. Пакет после ошибки продолжается.
|
||||||
|
|
||||||
|
**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому
|
||||||
|
команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL
|
||||||
|
сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры,
|
||||||
|
`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL
|
||||||
|
перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed`
|
||||||
|
или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать
|
||||||
|
сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался
|
||||||
|
`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести
|
||||||
|
заметку, не меняя статус (как для отменённой, см. `hub-api`).
|
||||||
|
|
||||||
|
**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб
|
||||||
|
SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия
|
||||||
|
`\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь
|
||||||
|
есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment` и `refresh` по состоянию не
|
||||||
|
подтверждаются: потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля)
|
||||||
|
или отменой человеком.
|
||||||
|
|
||||||
|
#### Scenario: Отчёт потерян, факт на месте
|
||||||
|
- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2»
|
||||||
|
- **THEN** команда `confirmed_by_state`
|
||||||
|
|
||||||
|
#### Scenario: Описание с CRLF в зеркале
|
||||||
|
- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"`
|
||||||
|
- **THEN** команда `confirmed_by_state`
|
||||||
|
|
||||||
|
#### Scenario: Ответ на claim потерян
|
||||||
|
- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim`
|
||||||
|
- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40
|
||||||
|
|
||||||
|
#### Scenario: Предел выдач
|
||||||
|
- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42
|
||||||
|
- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42
|
||||||
|
|
||||||
|
#### Scenario: Потерянный отчёт о создании задачи
|
||||||
|
- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim`
|
||||||
|
- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй
|
||||||
|
задачи нет, заметка `new:5` перенесена на ключ
|
||||||
99
openspec/changes/hub-hygiene-d/specs/hub-api/spec.md
Normal file
99
openspec/changes/hub-hygiene-d/specs/hub-api/spec.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Маршруты интерфейсов
|
||||||
|
Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном:
|
||||||
|
- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`,
|
||||||
|
`q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок;
|
||||||
|
сокращение до карточки — дело интерфейса.
|
||||||
|
- `GET /issues/{key}` — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней.
|
||||||
|
- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
|
||||||
|
- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных
|
||||||
|
параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с
|
||||||
|
одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы).
|
||||||
|
- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды
|
||||||
|
пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены
|
||||||
|
агентом; остановить — отмена команды с `force`).
|
||||||
|
- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N».
|
||||||
|
- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
|
||||||
|
- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`.
|
||||||
|
- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`;
|
||||||
|
ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`).
|
||||||
|
|
||||||
|
#### Scenario: Фильтр по спринту
|
||||||
|
- **WHEN** `GET /issues?sprint=Спринт 2`
|
||||||
|
- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу
|
||||||
|
|
||||||
|
#### Scenario: Отмена взятой команды
|
||||||
|
- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed`
|
||||||
|
- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется
|
||||||
|
|
||||||
|
#### Scenario: Поздний отчёт по отменённому созданию
|
||||||
|
- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620`
|
||||||
|
- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620`
|
||||||
|
|
||||||
|
#### Scenario: Дубль темы в пакете
|
||||||
|
- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"`
|
||||||
|
- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан
|
||||||
|
|
||||||
|
#### Scenario: Тема не строка
|
||||||
|
- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}`
|
||||||
|
- **THEN** 422 с указанием параметра `summary`, пакет не создан
|
||||||
|
|
||||||
|
#### Scenario: Отмена пакета с командой у агента
|
||||||
|
- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel`
|
||||||
|
- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed`
|
||||||
|
|
||||||
|
#### Scenario: Ответ на запись настроек
|
||||||
|
- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}`
|
||||||
|
- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки;
|
||||||
|
токенов в ответе нет
|
||||||
|
|
||||||
|
### Requirement: Публикация версии агента
|
||||||
|
`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка
|
||||||
|
`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `<JIRA_HUB_HOME>/release/agent.py`
|
||||||
|
(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл с `fsync` и
|
||||||
|
переименование, манифест последним. Прежний `agent.py` на время публикации SHALL переименовываться в `agent.py.prev`
|
||||||
|
и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (`OSError`), хаб SHALL вернуть
|
||||||
|
`agent.py.prev` на место `agent.py`, удалить временные файлы и завершить команду сообщением «не удалось записать
|
||||||
|
публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного
|
||||||
|
`serve` и печатать «опубликована v<N>, sha256 <первые 12 символов>…, <size> байт»; при ошибке — сообщение и код 1,
|
||||||
|
опубликованное не меняется. Хаб SHALL читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; манифест
|
||||||
|
годен, только если это объект, `version` — целое число (не логическое) не меньше 1, `sha256` — строка из 64 символов
|
||||||
|
`[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый
|
||||||
|
текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет»
|
||||||
|
(`GET /agent/release` — 404 `no_release`). `GET /agent` SHALL дополняться полями `release` (`{version, sha256,
|
||||||
|
published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` с момента запуска хаба)
|
||||||
|
и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент
|
||||||
|
онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v<to> на v<from>: <error>» (`rolled_back`),
|
||||||
|
«v<to> отвергнута: <error>» (`rejected`), иначе `null`.
|
||||||
|
|
||||||
|
#### Scenario: Публикация новой версии
|
||||||
|
- **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6`
|
||||||
|
- **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6,
|
||||||
|
sha256 …»; код 0
|
||||||
|
|
||||||
|
#### Scenario: Версия не выше опубликованной
|
||||||
|
- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6`
|
||||||
|
- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён
|
||||||
|
|
||||||
|
#### Scenario: Нет строки версии
|
||||||
|
- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками
|
||||||
|
- **THEN** сообщение об ошибке, код 1; `release/` не изменён
|
||||||
|
|
||||||
|
#### Scenario: Диск полон при записи манифеста
|
||||||
|
- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError`
|
||||||
|
- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8,
|
||||||
|
`GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/` нет
|
||||||
|
|
||||||
|
#### Scenario: Негодный манифест
|
||||||
|
- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim`
|
||||||
|
- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте
|
||||||
|
|
||||||
|
#### Scenario: Агент без запускателя
|
||||||
|
- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null`
|
||||||
|
- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную»
|
||||||
|
|
||||||
|
#### Scenario: Хаб перезапущен, агент ещё не выходил на связь
|
||||||
|
- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было
|
||||||
|
- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого
|
||||||
|
`claim` — `agent_reported = true`, `launcher_version` из `claim`
|
||||||
49
openspec/changes/hub-hygiene-d/specs/mcp/spec.md
Normal file
49
openspec/changes/hub-hygiene-d/specs/mcp/spec.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Инструменты
|
||||||
|
| Инструмент | Параметры | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
|
||||||
|
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды |
|
||||||
|
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
|
||||||
|
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
|
||||||
|
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
|
||||||
|
| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
|
||||||
|
| `batches` | `limit` | последние пакеты со статусом |
|
||||||
|
| `command_retry` | `id` | новый пакет |
|
||||||
|
| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» |
|
||||||
|
| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` |
|
||||||
|
| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть |
|
||||||
|
| `sync_log` | `limit` | последние `sync_run` |
|
||||||
|
| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
|
||||||
|
|
||||||
|
Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой
|
||||||
|
проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала.
|
||||||
|
|
||||||
|
Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
|
||||||
|
|
||||||
|
#### Scenario: Пакет из чата
|
||||||
|
- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
|
||||||
|
- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
|
||||||
|
|
||||||
|
#### Scenario: Ожидание результата
|
||||||
|
- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
|
||||||
|
- **THEN** ответ через 6 минут со статусами всех команд
|
||||||
|
|
||||||
|
#### Scenario: Статус агента с довыдачей
|
||||||
|
- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
|
||||||
|
- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
|
||||||
|
|
||||||
|
#### Scenario: Отмена пакета с командой у агента
|
||||||
|
- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed`
|
||||||
|
- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …»
|
||||||
|
|
||||||
|
#### Scenario: Статус агента после отката
|
||||||
|
- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7
|
||||||
|
- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и
|
||||||
|
«откат с v7 на v6: …»
|
||||||
|
|
||||||
|
#### Scenario: Статус агента сразу после перезапуска хаба
|
||||||
|
- **WHEN** хаб перезапущен, агент ещё не прислал `claim`
|
||||||
|
- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска
|
||||||
|
хаба)» и не содержит «без запускателя» и «агент запущен без run.py»
|
||||||
@ -20,6 +20,9 @@ packages = ["src/jira_hub"]
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
# незакрытое соединение sqlite — ошибка набора: ResourceWarning из __del__ pytest выдаёт как
|
||||||
|
# PytestUnraisableExceptionWarning, поэтому нужны оба фильтра (hub-hygiene-d, решение 14)
|
||||||
|
filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExceptionWarning"]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
# Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS
|
# Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS
|
||||||
|
|||||||
@ -50,7 +50,9 @@ class Hub:
|
|||||||
self.web_dir = Path(web_dir) if web_dir else WEB_APP
|
self.web_dir = Path(web_dir) if web_dir else WEB_APP
|
||||||
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
||||||
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
|
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
|
||||||
self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3)
|
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
|
||||||
|
# неизвестен, а не null (hub-hygiene-d, решение 7)
|
||||||
|
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
|
||||||
self.claim_wait = 30
|
self.claim_wait = 30
|
||||||
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
||||||
conn = self.conn()
|
conn = self.conn()
|
||||||
@ -74,15 +76,18 @@ class Hub:
|
|||||||
|
|
||||||
def note_agent_update(self, body):
|
def note_agent_update(self, body):
|
||||||
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
|
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
|
||||||
Значение неверного типа игнорируется — claim из-за него не отвергается."""
|
Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
|
||||||
|
new = dict(self.agent_update)
|
||||||
|
new["reported"] = True
|
||||||
us = body.get("update_state")
|
us = body.get("update_state")
|
||||||
if us is None or isinstance(us, dict):
|
if us is None or isinstance(us, dict):
|
||||||
if us is not None and isinstance(us.get("error"), str):
|
if us is not None and isinstance(us.get("error"), str):
|
||||||
us = {**us, "error": us["error"][:500]}
|
us = {**us, "error": us["error"][:500]}
|
||||||
self.agent_update["update_state"] = us
|
new["update_state"] = us
|
||||||
lv = body.get("launcher_version")
|
lv = body.get("launcher_version")
|
||||||
if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)):
|
if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)):
|
||||||
self.agent_update["launcher_version"] = lv
|
new["launcher_version"] = lv
|
||||||
|
self.agent_update = new
|
||||||
|
|
||||||
def make_backup(self, label):
|
def make_backup(self, label):
|
||||||
conn = self.conn()
|
conn = self.conn()
|
||||||
@ -290,8 +295,10 @@ def h_refresh(hub, conn, m, q, body):
|
|||||||
def h_agent_get(hub, conn, m, q, body):
|
def h_agent_get(hub, conn, m, q, body):
|
||||||
view = queue.agent_view(conn)
|
view = queue.agent_view(conn)
|
||||||
rel = release.current(hub.release_dir)
|
rel = release.current(hub.release_dir)
|
||||||
us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"]
|
au = hub.agent_update
|
||||||
view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv))
|
us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
|
||||||
|
view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
|
||||||
|
update_hint=release.hint(rel, view, us, lv, reported=reported))
|
||||||
return 200, view
|
return 200, view
|
||||||
|
|
||||||
|
|
||||||
@ -306,7 +313,8 @@ def h_settings_get(hub, conn, m, q, body):
|
|||||||
|
|
||||||
def h_settings_put(hub, conn, m, q, body):
|
def h_settings_put(hub, conn, m, q, body):
|
||||||
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
|
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
|
||||||
(settings.validate): ошибка — 422, версия настроек не меняется."""
|
(settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у
|
||||||
|
GET /settings (hub-hygiene-d, решение 9)."""
|
||||||
bad = [k for k in body if k in settings.PROTECTED]
|
bad = [k for k in body if k in settings.PROTECTED]
|
||||||
if bad:
|
if bad:
|
||||||
raise ApiError(422, "protected", ", ".join(bad))
|
raise ApiError(422, "protected", ", ".join(bad))
|
||||||
@ -315,11 +323,10 @@ def h_settings_put(hub, conn, m, q, body):
|
|||||||
settings.validate(key, value)
|
settings.validate(key, value)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ApiError(422, "bad_setting", str(e))
|
raise ApiError(422, "bad_setting", str(e))
|
||||||
version = settings.get(conn, "settings_version")
|
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
for key, value in body.items():
|
for key, value in body.items():
|
||||||
version = settings.set_(conn, key, value)
|
settings.set_(conn, key, value)
|
||||||
return 200, {"settings_version": version}
|
return 200, settings.get_all(conn)
|
||||||
|
|
||||||
|
|
||||||
ROUTES = [
|
ROUTES = [
|
||||||
|
|||||||
@ -57,15 +57,16 @@ def _type_error(ctype, name, value) -> str | None:
|
|||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if name in LIST_PARAMS:
|
if name in LIST_PARAMS:
|
||||||
ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк"
|
return None if _is_str_list(value) else f"{name} — список строк"
|
||||||
elif name in BOOL_PARAMS:
|
if name in BOOL_PARAMS:
|
||||||
ok, want = isinstance(value, bool), "true или false"
|
return None if isinstance(value, bool) else f"{name} — true или false"
|
||||||
elif ctype == "ensure_status" and name == "target":
|
if ctype == "ensure_status" and name == "target":
|
||||||
ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value))
|
return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк"
|
||||||
want = "строка или список строк"
|
return None if isinstance(value, str) else f"{name} — строка"
|
||||||
else:
|
|
||||||
ok, want = isinstance(value, str), "строка"
|
|
||||||
return None if ok else f"{name} — {want}"
|
def _is_str_list(value) -> bool:
|
||||||
|
return isinstance(value, list) and all(isinstance(v, str) for v in value)
|
||||||
|
|
||||||
|
|
||||||
def validate(commands) -> list[dict]:
|
def validate(commands) -> list[dict]:
|
||||||
@ -133,6 +134,14 @@ def estimate(types, pacing: dict) -> int:
|
|||||||
return sum(int(pacing.get(t, 0)) for t in types)
|
return sum(int(pacing.get(t, 0)) for t in types)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_text(value):
|
||||||
|
"""Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira,
|
||||||
|
приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11)."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.replace("\r\n", "\n")
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
|
def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
|
||||||
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются:
|
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются:
|
||||||
потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability,
|
потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability,
|
||||||
@ -145,7 +154,7 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
|
|||||||
return False, None
|
return False, None
|
||||||
if ctype == "ensure_fields":
|
if ctype == "ensure_fields":
|
||||||
for field in FIELD_PARAMS:
|
for field in FIELD_PARAMS:
|
||||||
if field in p and (issue.get(field) or None) != (p[field] or None):
|
if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
|
||||||
return False, None
|
return False, None
|
||||||
if any(l not in issue["labels"] for l in p.get("labels_add", [])):
|
if any(l not in issue["labels"] for l in p.get("labels_add", [])):
|
||||||
return False, None
|
return False, None
|
||||||
|
|||||||
@ -112,6 +112,9 @@ def fmt_agent(a) -> str:
|
|||||||
if us:
|
if us:
|
||||||
out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}")
|
out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}")
|
||||||
lv = a.get("launcher_version")
|
lv = a.get("launcher_version")
|
||||||
|
if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
|
||||||
|
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
|
||||||
|
else:
|
||||||
out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")
|
out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")
|
||||||
if a.get("update_hint"):
|
if a.get("update_hint"):
|
||||||
out.append(a["update_hint"])
|
out.append(a["update_hint"])
|
||||||
|
|||||||
@ -114,6 +114,12 @@ def _definer_status(conn, batch_id, temp_key):
|
|||||||
return r["status"] if r else "failed"
|
return r["status"] if r else "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_reason(conn, batch_id, unresolved) -> str:
|
||||||
|
"""Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10)."""
|
||||||
|
cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"]
|
||||||
|
return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана"
|
||||||
|
|
||||||
|
|
||||||
def _next(conn) -> dict | None:
|
def _next(conn) -> dict | None:
|
||||||
rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id "
|
rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id "
|
||||||
"WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall()
|
"WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall()
|
||||||
@ -131,7 +137,7 @@ def _next(conn) -> dict | None:
|
|||||||
unresolved = [] if cmd["params"]["keys"] else unresolved
|
unresolved = [] if cmd["params"]["keys"] else unresolved
|
||||||
if unresolved:
|
if unresolved:
|
||||||
conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?",
|
conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?",
|
||||||
(db.now(), f"задача {unresolved[0]} не создана", cmd["id"]))
|
(db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"]))
|
||||||
continue
|
continue
|
||||||
return cmd
|
return cmd
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class Relay:
|
|||||||
self._cleanup_at = 0.0
|
self._cleanup_at = 0.0
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._warned: str | None = None
|
self._warned: str | None = None
|
||||||
self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле
|
self._cleaned: set[Path] = set() # папки (по resolve()), чей work/ вычищен после старта реле
|
||||||
self.workers: list[threading.Thread] = []
|
self.workers: list[threading.Thread] = []
|
||||||
|
|
||||||
# --- цикл ---------------------------------------------------------------------
|
# --- цикл ---------------------------------------------------------------------
|
||||||
@ -124,12 +124,13 @@ class Relay:
|
|||||||
def _prepare(self) -> None:
|
def _prepare(self) -> None:
|
||||||
for sub in ("req", "resp", "work"):
|
for sub in ("req", "resp", "work"):
|
||||||
(self.root / sub).mkdir(exist_ok=True)
|
(self.root / sub).mkdir(exist_ok=True)
|
||||||
if self.root not in self._cleaned:
|
key = _same_dir_key(self.root)
|
||||||
|
if key not in self._cleaned:
|
||||||
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
|
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
|
||||||
for stale in (self.root / "work").iterdir():
|
for stale in (self.root / "work").iterdir():
|
||||||
stale.unlink(missing_ok=True)
|
stale.unlink(missing_ok=True)
|
||||||
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
|
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
|
||||||
self._cleaned.add(self.root)
|
self._cleaned.add(key)
|
||||||
else:
|
else:
|
||||||
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
|
||||||
say(f"relay: папка {self.root} подготовлена заново")
|
say(f"relay: папка {self.root} подготовлена заново")
|
||||||
@ -225,6 +226,15 @@ class Relay:
|
|||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_dir_key(path: Path) -> Path:
|
||||||
|
"""Ключ папки для «уже вычищена»: resolve() — симлинк и другая запись той же папки совпадают (minor-1 #16,
|
||||||
|
решение 12); ошибка resolve — путь как записан."""
|
||||||
|
try:
|
||||||
|
return path.resolve()
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _error(error: str, detail: str) -> str:
|
def _error(error: str, detail: str) -> str:
|
||||||
return json.dumps({"error": error, "detail": detail}, ensure_ascii=False)
|
return json.dumps({"error": error, "detail": detail}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
|
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
|
||||||
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5."""
|
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7."""
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -15,6 +16,11 @@ log = logging.getLogger(__name__)
|
|||||||
MAX_SIZE = 1024 * 1024
|
MAX_SIZE = 1024 * 1024
|
||||||
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
|
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
|
||||||
|
|
||||||
|
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
||||||
|
|
||||||
|
_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5)
|
||||||
|
_warned_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class PublishError(Exception):
|
class PublishError(Exception):
|
||||||
pass
|
pass
|
||||||
@ -28,13 +34,27 @@ def parse_version(text: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _write_atomic(path: Path, data: bytes) -> None:
|
def _write_atomic(path: Path, data: bytes) -> None:
|
||||||
|
"""Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1).
|
||||||
|
При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается."""
|
||||||
tmp = path.with_name("." + path.name + ".tmp")
|
tmp = path.with_name("." + path.name + ".tmp")
|
||||||
tmp.write_bytes(data)
|
try:
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def publish(release_dir, source) -> dict:
|
def publish(release_dir, source) -> dict:
|
||||||
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной)."""
|
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
|
||||||
|
Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
|
||||||
|
опубликованной остаётся прежняя версия, наружу — только PublishError."""
|
||||||
src = Path(source)
|
src = Path(source)
|
||||||
try:
|
try:
|
||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
@ -51,25 +71,80 @@ def publish(release_dir, source) -> dict:
|
|||||||
prev = current(rd)
|
prev = current(rd)
|
||||||
if prev and version <= prev["version"]:
|
if prev and version <= prev["version"]:
|
||||||
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
|
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
|
||||||
secure_dir(rd)
|
|
||||||
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
|
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
|
||||||
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
|
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
|
||||||
_write_atomic(rd / "agent.py", data)
|
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
|
||||||
|
existed, moved = agent.exists(), False
|
||||||
|
try:
|
||||||
|
secure_dir(rd)
|
||||||
|
if agent.exists():
|
||||||
|
os.replace(agent, prev_file)
|
||||||
|
moved = True
|
||||||
|
_write_atomic(agent, data)
|
||||||
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
|
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
|
||||||
|
except OSError as e:
|
||||||
|
_undo(agent, prev_file, moved, existed)
|
||||||
|
raise PublishError(f"не удалось записать публикацию: {e}") from e
|
||||||
|
try:
|
||||||
|
prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design)
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("release: %s не удалён: %s", prev_file, e)
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
|
||||||
|
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
|
||||||
|
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
|
||||||
|
Сбой возврата — в лог: команда всё равно завершается сообщением."""
|
||||||
|
try:
|
||||||
|
if moved:
|
||||||
|
os.replace(prev_file, agent)
|
||||||
|
elif not existed:
|
||||||
|
agent.unlink(missing_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_problem(m) -> str | None:
|
||||||
|
"""Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9)."""
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
return "не объект"
|
||||||
|
version = m.get("version")
|
||||||
|
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||||||
|
return f"version — не целое ≥ 1: {version!r}"
|
||||||
|
sha = m.get("sha256")
|
||||||
|
if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
|
||||||
|
return "sha256 — не 64 символа [0-9a-f]"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _note_manifest(problem: str | None, path: Path) -> None:
|
||||||
|
"""Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест
|
||||||
|
сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков."""
|
||||||
|
global _warned
|
||||||
|
text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет"
|
||||||
|
with _warned_lock:
|
||||||
|
if text is not None and text != _warned:
|
||||||
|
log.warning("%s", text)
|
||||||
|
_warned = text
|
||||||
|
|
||||||
|
|
||||||
def current(release_dir) -> dict | None:
|
def current(release_dir) -> dict | None:
|
||||||
"""Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять)."""
|
"""Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять)."""
|
||||||
path = Path(release_dir) / "manifest.json"
|
path = Path(release_dir) / "manifest.json"
|
||||||
try:
|
try:
|
||||||
m = json.loads(path.read_text(encoding="utf-8"))
|
m = json.loads(path.read_text(encoding="utf-8"))
|
||||||
return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")}
|
|
||||||
except (FileNotFoundError, NotADirectoryError):
|
except (FileNotFoundError, NotADirectoryError):
|
||||||
|
_note_manifest(None, path)
|
||||||
return None
|
return None
|
||||||
except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает
|
except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает
|
||||||
log.warning("release: манифест %s не читается (%s) — публикации нет", path, e)
|
_note_manifest(f"не читается ({e})", path)
|
||||||
return None
|
return None
|
||||||
|
problem = _manifest_problem(m)
|
||||||
|
_note_manifest(problem, path)
|
||||||
|
if problem:
|
||||||
|
return None
|
||||||
|
return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")}
|
||||||
|
|
||||||
|
|
||||||
def load(release_dir) -> dict | None:
|
def load(release_dir) -> dict | None:
|
||||||
@ -85,16 +160,23 @@ def load(release_dir) -> dict | None:
|
|||||||
if hashlib.sha256(data).hexdigest() != m["sha256"]:
|
if hashlib.sha256(data).hexdigest() != m["sha256"]:
|
||||||
log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет")
|
log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет")
|
||||||
return None
|
return None
|
||||||
return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")}
|
try:
|
||||||
|
content = data.decode("utf-8")
|
||||||
|
except UnicodeDecodeError: # решение 6: 404, а не 500
|
||||||
|
log.error("release: agent.py не в UTF-8 — публикации нет")
|
||||||
|
return None
|
||||||
|
return {"version": m["version"], "sha256": m["sha256"], "content": content}
|
||||||
|
|
||||||
|
|
||||||
def hint(rel, agent, update_state, launcher_version) -> str | None:
|
def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None:
|
||||||
"""Подсказка для agent_status (design решение 5)."""
|
"""Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim
|
||||||
|
ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7)."""
|
||||||
try:
|
try:
|
||||||
agent_version = int(agent.get("agent_version") or 0)
|
agent_version = int(agent.get("agent_version") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
agent_version = 0
|
agent_version = 0
|
||||||
if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None:
|
if (reported and rel and agent.get("online") and agent_version and rel["version"] > agent_version
|
||||||
|
and launcher_version is None):
|
||||||
return "агент запущен без run.py — обновится только вручную"
|
return "агент запущен без run.py — обновится только вручную"
|
||||||
us = update_state or {}
|
us = update_state or {}
|
||||||
if us.get("state") == "rolled_back":
|
if us.get("state") == "rolled_back":
|
||||||
|
|||||||
20
tests/conftest.py
Normal file
20
tests/conftest.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет
|
||||||
|
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from jira_hub import db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def open_db():
|
||||||
|
"""Фабрика соединений: open_db() — база в памяти, open_db(path) — файл. Все закрываются после теста."""
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def make(path=":memory:"):
|
||||||
|
conn = db.connect(path)
|
||||||
|
opened.append(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
yield make
|
||||||
|
for conn in opened:
|
||||||
|
conn.close()
|
||||||
@ -3,10 +3,11 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from jira_hub import api, db, release, settings
|
from jira_hub import api, db, queue, release, settings
|
||||||
from jira_hub.client import HubClient, HubError
|
from jira_hub.client import HubClient, HubError
|
||||||
|
|
||||||
|
|
||||||
@ -208,13 +209,15 @@ def test_numeric_param_400_internal_bug_500(hub, monkeypatch):
|
|||||||
assert e.value.status == 500
|
assert e.value.status == 500
|
||||||
|
|
||||||
|
|
||||||
def test_tick_survives_connection_failure(hub, monkeypatch):
|
def test_tick_survives_connection_failure(hub, monkeypatch, caplog):
|
||||||
def broken():
|
def broken():
|
||||||
raise OSError("диск недоступен")
|
raise OSError("диск недоступен")
|
||||||
monkeypatch.setattr(hub["hub"], "conn", broken)
|
monkeypatch.setattr(hub["hub"], "conn", broken)
|
||||||
hub["hub"].tick() # не бросает
|
hub["hub"].tick() # не бросает
|
||||||
|
assert "автообновление: сбой тика" in caplog.text
|
||||||
monkeypatch.undo()
|
monkeypatch.undo()
|
||||||
hub["hub"].tick()
|
hub["hub"].tick()
|
||||||
|
assert hub["client"].get("/batches")["batches"] == [] # агент не на связи — автообновления нет
|
||||||
|
|
||||||
|
|
||||||
def test_claim_long_poll_returns_new_command(hub):
|
def test_claim_long_poll_returns_new_command(hub):
|
||||||
@ -428,11 +431,13 @@ def test_hint_without_launcher(hub, tmp_path):
|
|||||||
hub["agent"].post("/agent/claim", {"agent_version": 5})
|
hub["agent"].post("/agent/claim", {"agent_version": 5})
|
||||||
v = hub["client"].get("/agent")
|
v = hub["client"].get("/agent")
|
||||||
assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"
|
assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"
|
||||||
|
assert v["agent_reported"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_agent_view_without_release(hub):
|
def test_agent_view_without_release(hub):
|
||||||
v = hub["client"].get("/agent")
|
v = hub["client"].get("/agent")
|
||||||
assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None
|
assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None
|
||||||
|
assert v["agent_reported"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_claim_survives_unreadable_release(hub):
|
def test_claim_survives_unreadable_release(hub):
|
||||||
@ -452,3 +457,118 @@ def test_claim_survives_unreadable_release(hub):
|
|||||||
assert b["id"]
|
assert b["id"]
|
||||||
finally:
|
finally:
|
||||||
os.chmod(rd, 0o700)
|
os.chmod(rd, 0o700)
|
||||||
|
|
||||||
|
|
||||||
|
# --- hub-hygiene-d ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog):
|
||||||
|
"""M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение."""
|
||||||
|
monkeypatch.setattr(release, "_warned", None)
|
||||||
|
rd = hub["hub"].release_dir
|
||||||
|
rd.mkdir()
|
||||||
|
(rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
|
||||||
|
for _ in range(10):
|
||||||
|
assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None
|
||||||
|
assert hub["client"].get("/agent")["release"] is None
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
hub["agent"].get("/agent/release")
|
||||||
|
assert e.value.status == 404 and e.value.error == "no_release"
|
||||||
|
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_release_non_utf8_is_404(hub, tmp_path):
|
||||||
|
"""M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500."""
|
||||||
|
import hashlib
|
||||||
|
_publish(hub, tmp_path, 6)
|
||||||
|
rd = hub["hub"].release_dir
|
||||||
|
data = b"AGENT_VERSION = 6\n\xff\n"
|
||||||
|
(rd / "agent.py").write_bytes(data)
|
||||||
|
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
(rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8")
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
hub["agent"].get("/agent/release")
|
||||||
|
assert e.value.status == 404 and e.value.error == "no_release"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_reported_after_hub_restart(hub, tmp_path):
|
||||||
|
"""M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было."""
|
||||||
|
_publish(hub, tmp_path, 10)
|
||||||
|
conn = hub["hub"].conn()
|
||||||
|
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),))
|
||||||
|
conn.close()
|
||||||
|
v = hub["client"].get("/agent")
|
||||||
|
assert v["online"] is True and v["agent_reported"] is False
|
||||||
|
assert v["launcher_version"] is None and v["update_hint"] is None
|
||||||
|
hub["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
|
||||||
|
v = hub["client"].get("/agent")
|
||||||
|
assert v["agent_reported"] is True and v["launcher_version"] == 2 and v["update_hint"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_reported_even_with_bad_update_fields(hub, tmp_path):
|
||||||
|
"""Review Focus 5: claim с полями неверного типа — claim всё равно был: agent_reported = true."""
|
||||||
|
_publish(hub, tmp_path, 6)
|
||||||
|
hub["agent"].post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"})
|
||||||
|
v = hub["client"].get("/agent")
|
||||||
|
assert v["agent_reported"] is True and v["launcher_version"] is None
|
||||||
|
assert v["update_hint"] == "агент запущен без run.py — обновится только вручную"
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_put_returns_all_settings(hub):
|
||||||
|
"""O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов."""
|
||||||
|
c = hub["client"]
|
||||||
|
v0 = c.get("/settings")["settings_version"]
|
||||||
|
out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"})
|
||||||
|
assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1
|
||||||
|
assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA"
|
||||||
|
assert out == c.get("/settings")
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_put_empty_body_returns_settings_unchanged(hub):
|
||||||
|
"""Пустое тело — 200, все настройки, версия не меняется."""
|
||||||
|
c = hub["client"]
|
||||||
|
before = c.get("/settings")
|
||||||
|
assert c.put("/settings", {}) == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["DELETE", "PATCH"])
|
||||||
|
def test_other_methods_are_501_not_json(hub, method):
|
||||||
|
"""M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется."""
|
||||||
|
import http.client
|
||||||
|
hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields",
|
||||||
|
"params": {"key": "A-1", "duedate": "2026-10-23"}}]})
|
||||||
|
host, port = hub["base"].removeprefix("http://").split(":")
|
||||||
|
conn = http.client.HTTPConnection(host, int(port), timeout=10)
|
||||||
|
try:
|
||||||
|
conn.request(method, "/batches/1", headers={"Authorization": "Bearer " + hub["client"].token})
|
||||||
|
r = conn.getresponse()
|
||||||
|
r.read()
|
||||||
|
assert r.status == 501 and not (r.getheader("Content-Type") or "").startswith("application/json")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert hub["client"].get("/batches/1")["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_warning_and_offline(hub):
|
||||||
|
"""M-10 #4: «обновите агента» при agent_version < min_agent_version; нет контакта дольше порога — офлайн."""
|
||||||
|
c = hub["client"]
|
||||||
|
c.put("/settings", {"min_agent_version": 3})
|
||||||
|
hub["agent"].post("/agent/claim", {"agent_version": 2})
|
||||||
|
v = c.get("/agent")
|
||||||
|
assert v["online"] is True and v["warning"] == "обновите агента"
|
||||||
|
hub["agent"].post("/agent/claim", {"agent_version": 3})
|
||||||
|
assert c.get("/agent")["warning"] is None
|
||||||
|
old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat()
|
||||||
|
conn = hub["hub"].conn()
|
||||||
|
conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,))
|
||||||
|
conn.close()
|
||||||
|
assert c.get("/agent")["online"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tick_creates_auto_refresh_once(hub):
|
||||||
|
"""M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята)."""
|
||||||
|
hub["agent"].post("/agent/claim", {"agent_version": 9})
|
||||||
|
hub["hub"].tick()
|
||||||
|
batches = hub["client"].get("/batches", limit=5)["batches"]
|
||||||
|
assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub"
|
||||||
|
hub["hub"].tick()
|
||||||
|
assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jira_hub import cli, db, paths, settings
|
from jira_hub import cli, paths, release, settings
|
||||||
|
|
||||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||||
|
|
||||||
|
|
||||||
def test_init_import_tokens(tmp_path, monkeypatch, capsys):
|
def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
|
||||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
|
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "transitions_map" in out and "агентский токен" in out
|
assert "transitions_map" in out and "агентский токен" in out
|
||||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
|
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
|
||||||
assert "уже инициализирован" in capsys.readouterr().err
|
assert "уже инициализирован" in capsys.readouterr().err
|
||||||
conn = db.connect(paths.db_path())
|
conn = open_db(paths.db_path())
|
||||||
assert settings.get(conn, "board_id") == 4216
|
assert settings.get(conn, "board_id") == 4216
|
||||||
(tmp_path / "pkg").mkdir()
|
(tmp_path / "pkg").mkdir()
|
||||||
(tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8")
|
(tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8")
|
||||||
@ -22,7 +23,6 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys):
|
|||||||
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"]
|
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"]
|
||||||
assert cli.main(["tokens"]) == 0
|
assert cli.main(["tokens"]) == 0
|
||||||
assert settings.tokens(conn)["client"] in capsys.readouterr().out
|
assert settings.tokens(conn)["client"] in capsys.readouterr().out
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_publish(tmp_path, monkeypatch, capsys):
|
def test_agent_publish(tmp_path, monkeypatch, capsys):
|
||||||
@ -35,3 +35,27 @@ def test_agent_publish(tmp_path, monkeypatch, capsys):
|
|||||||
assert (paths.release_dir() / "manifest.json").exists()
|
assert (paths.release_dir() / "manifest.json").exists()
|
||||||
assert cli.main(["agent", "publish", str(src)]) == 1
|
assert cli.main(["agent", "publish", str(src)]) == 1
|
||||||
assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err
|
assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
|
||||||
|
"""Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25)."""
|
||||||
|
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||||
|
src = tmp_path / "agent.py"
|
||||||
|
src.write_text("AGENT_VERSION = 8\n", encoding="utf-8")
|
||||||
|
assert cli.main(["agent", "publish", str(src)]) == 0
|
||||||
|
capsys.readouterr()
|
||||||
|
real = os.replace
|
||||||
|
|
||||||
|
def fake(s, d, *a, **kw):
|
||||||
|
if Path(d).name == "manifest.json":
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
return real(s, d, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "replace", fake)
|
||||||
|
src.write_text("AGENT_VERSION = 9\n", encoding="utf-8")
|
||||||
|
assert cli.main(["agent", "publish", str(src)]) == 1
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert err.startswith("не удалось записать публикацию: ") and "Traceback" not in err
|
||||||
|
monkeypatch.setattr(os, "replace", real)
|
||||||
|
assert release.load(paths.release_dir())["version"] == 8
|
||||||
|
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from jira_hub import commands, db, mirror
|
from jira_hub import commands, mirror
|
||||||
from jira_hub.commands import CommandError
|
from jira_hub.commands import CommandError
|
||||||
|
|
||||||
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
|
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
|
||||||
@ -48,15 +48,17 @@ def test_substitute_and_estimate():
|
|||||||
assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152
|
assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152
|
||||||
|
|
||||||
|
|
||||||
def _mirror():
|
@pytest.fixture
|
||||||
conn = db.connect(":memory:")
|
def conn(open_db):
|
||||||
mirror.apply_state(conn, [
|
"""Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста."""
|
||||||
|
c = open_db()
|
||||||
|
mirror.apply_state(c, [
|
||||||
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
|
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
|
||||||
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
|
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
|
||||||
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
|
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
|
||||||
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
|
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
|
||||||
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
|
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
|
||||||
return conn
|
return c
|
||||||
|
|
||||||
|
|
||||||
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
|
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
|
||||||
@ -78,27 +80,24 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на
|
|||||||
({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
|
({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
|
||||||
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
|
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
|
||||||
])
|
])
|
||||||
def test_check_confirmed(cmd, expected):
|
def test_check_confirmed(conn, cmd, expected):
|
||||||
ok, _ = commands.check_confirmed(_mirror(), cmd, LT)
|
ok, _ = commands.check_confirmed(conn, cmd, LT)
|
||||||
assert ok is expected
|
assert ok is expected
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_link_reverse_direction_with_both_keys_in_mirror():
|
def test_ensure_link_reverse_direction_with_both_keys_in_mirror(conn):
|
||||||
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
|
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
|
||||||
conn = _mirror()
|
|
||||||
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
|
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_link_conflict_other_type_blocks_confirmation():
|
def test_ensure_link_conflict_other_type_blocks_confirmation(conn):
|
||||||
conn = _mirror()
|
|
||||||
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
|
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
|
||||||
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
|
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_link_conflict_reverse_blocks_confirmation():
|
def test_ensure_link_conflict_reverse_blocks_confirmation(conn):
|
||||||
conn = _mirror()
|
|
||||||
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
|
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
|
||||||
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
|
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
@ -108,9 +107,8 @@ def _ensure_issue(summary):
|
|||||||
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
|
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_issue_never_confirmed_by_state():
|
def test_ensure_issue_never_confirmed_by_state(conn):
|
||||||
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
|
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
|
||||||
conn = _mirror()
|
|
||||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||||
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
|
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
|
||||||
|
|
||||||
@ -149,3 +147,62 @@ def test_validate_accepts_null_for_clearable_and_path_target():
|
|||||||
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
|
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
|
||||||
])
|
])
|
||||||
assert len(out) == 3
|
assert len(out) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mirror_value, param, expected", [
|
||||||
|
("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True),
|
||||||
|
("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False),
|
||||||
|
])
|
||||||
|
def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected):
|
||||||
|
"""O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст.
|
||||||
|
summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей."""
|
||||||
|
conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny"))
|
||||||
|
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
|
||||||
|
"params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT)
|
||||||
|
assert ok is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_fields_summary_difference_is_detected(conn):
|
||||||
|
"""T4.1 #32: CRLF в summary игнорируется, но реальное расхождение текста — False."""
|
||||||
|
conn.execute("UPDATE issue SET summary = ? WHERE key = 'A-1'", ("x\r\ny",))
|
||||||
|
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
|
||||||
|
"params": {"key": "A-1", "summary": "x\nz"}}, LT)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ctype, name, value, msg", [
|
||||||
|
("ensure_fields", "duedate", True, "duedate — строка"),
|
||||||
|
("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"),
|
||||||
|
("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"),
|
||||||
|
("ensure_link", "replace", "да", "replace — true или false"),
|
||||||
|
("ensure_status", "target", 5, "target — строка или список строк"),
|
||||||
|
("ensure_status", "target", ["Разработка", "Готово"], None),
|
||||||
|
("ensure_status", "target", "Готово", None),
|
||||||
|
("ensure_fields", "labels_add", [], None),
|
||||||
|
("ensure_fields", "assignee", None, None),
|
||||||
|
("ensure_link", "replace", False, None),
|
||||||
|
])
|
||||||
|
def test_type_error_messages(ctype, name, value, msg):
|
||||||
|
"""T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один)."""
|
||||||
|
assert commands._type_error(ctype, name, value) == msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cmd, name", [
|
||||||
|
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"),
|
||||||
|
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"),
|
||||||
|
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"),
|
||||||
|
])
|
||||||
|
def test_validate_rejects_bool_in_string_param(cmd, name):
|
||||||
|
"""T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira."""
|
||||||
|
with pytest.raises(CommandError, match=f"{name} — строка"):
|
||||||
|
commands.validate([cmd])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_empty_list_params():
|
||||||
|
"""T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие."""
|
||||||
|
out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}])
|
||||||
|
assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []}
|
||||||
|
with pytest.raises(CommandError, match="нет target"):
|
||||||
|
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}])
|
||||||
|
with pytest.raises(CommandError, match="refresh без keys и jql"):
|
||||||
|
commands.validate([{"type": "refresh", "params": {"keys": []}}])
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import threading
|
|||||||
from jira_hub import backup, db, paths, queue, settings
|
from jira_hub import backup, db, paths, queue, settings
|
||||||
|
|
||||||
|
|
||||||
def test_connect_creates_schema():
|
def test_connect_creates_schema(open_db):
|
||||||
conn = db.connect(":memory:")
|
conn = open_db()
|
||||||
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||||
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
|
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
|
||||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
|
||||||
@ -59,10 +59,11 @@ def test_connect_is_idempotent(tmp_path):
|
|||||||
db.connect(path).close()
|
db.connect(path).close()
|
||||||
conn = db.connect(path)
|
conn = db.connect(path)
|
||||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def test_transaction_rolls_back_on_error(tmp_path):
|
def test_transaction_rolls_back_on_error(tmp_path, open_db):
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
try:
|
try:
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
|
||||||
@ -72,8 +73,8 @@ def test_transaction_rolls_back_on_error(tmp_path):
|
|||||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_nested_transaction_joins_outer(tmp_path):
|
def test_nested_transaction_joins_outer(tmp_path, open_db):
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
try:
|
try:
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
@ -86,8 +87,8 @@ def test_nested_transaction_joins_outer(tmp_path):
|
|||||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную
|
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную
|
||||||
|
|
||||||
|
|
||||||
def test_nested_transaction_error_propagates_to_outer(tmp_path):
|
def test_nested_transaction_error_propagates_to_outer(tmp_path, open_db):
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
try:
|
try:
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')")
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')")
|
||||||
@ -103,8 +104,8 @@ def test_parse_and_utcnow():
|
|||||||
assert db.parse(db.now()) <= db.utcnow()
|
assert db.parse(db.now()) <= db.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def test_nested_transaction_rollback_is_local(tmp_path):
|
def test_nested_transaction_rollback_is_local(tmp_path, open_db):
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')")
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
import csv
|
import csv
|
||||||
|
|
||||||
from jira_hub import db, importer, mirror, settings
|
import pytest
|
||||||
|
|
||||||
|
from jira_hub import importer, mirror, settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(open_db):
|
||||||
|
"""База в памяти с настройками по умолчанию; закрывается после теста."""
|
||||||
|
c = open_db()
|
||||||
|
settings.ensure_defaults(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
def write_csv(path, header, rows):
|
def write_csv(path, header, rows):
|
||||||
@ -29,10 +39,8 @@ def make_package(folder):
|
|||||||
["new:3", "связанные", "AURORA-1533", "z"]])
|
["new:3", "связанные", "AURORA-1533", "z"]])
|
||||||
|
|
||||||
|
|
||||||
def test_import_notes_and_report(tmp_path):
|
def test_import_notes_and_report(conn, tmp_path):
|
||||||
make_package(tmp_path)
|
make_package(tmp_path)
|
||||||
conn = db.connect(":memory:")
|
|
||||||
settings.ensure_defaults(conn)
|
|
||||||
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
|
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
|
||||||
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
|
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
|
||||||
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
|
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
|
||||||
@ -60,10 +68,8 @@ def write_tasks(folder, comment):
|
|||||||
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]])
|
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]])
|
||||||
|
|
||||||
|
|
||||||
def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path):
|
def test_reimport_keeps_hub_edit_when_cell_empty(conn, tmp_path):
|
||||||
write_tasks(tmp_path, "Актуализация 10.10.2026")
|
write_tasks(tmp_path, "Актуализация 10.10.2026")
|
||||||
conn = db.connect(":memory:")
|
|
||||||
settings.ensure_defaults(conn)
|
|
||||||
importer.import_package(conn, tmp_path)
|
importer.import_package(conn, tmp_path)
|
||||||
mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub")
|
mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub")
|
||||||
write_tasks(tmp_path, "") # пустая ячейка в CSV
|
write_tasks(tmp_path, "") # пустая ячейка в CSV
|
||||||
@ -73,20 +79,16 @@ def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path):
|
|||||||
assert n["rest_text"] == "Прогнать"
|
assert n["rest_text"] == "Прогнать"
|
||||||
|
|
||||||
|
|
||||||
def test_reimport_updates_nonempty_cell(tmp_path):
|
def test_reimport_updates_nonempty_cell(conn, tmp_path):
|
||||||
write_tasks(tmp_path, "Первая версия")
|
write_tasks(tmp_path, "Первая версия")
|
||||||
conn = db.connect(":memory:")
|
|
||||||
settings.ensure_defaults(conn)
|
|
||||||
importer.import_package(conn, tmp_path)
|
importer.import_package(conn, tmp_path)
|
||||||
write_tasks(tmp_path, "Вторая версия")
|
write_tasks(tmp_path, "Вторая версия")
|
||||||
importer.import_package(conn, tmp_path)
|
importer.import_package(conn, tmp_path)
|
||||||
assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия"
|
assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия"
|
||||||
|
|
||||||
|
|
||||||
def test_import_does_not_touch_issue_tables(tmp_path):
|
def test_import_does_not_touch_issue_tables(conn, tmp_path):
|
||||||
make_package(tmp_path)
|
make_package(tmp_path)
|
||||||
conn = db.connect(":memory:")
|
|
||||||
settings.ensure_defaults(conn)
|
|
||||||
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}],
|
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}],
|
||||||
[{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}])
|
[{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}])
|
||||||
count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"]
|
count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"]
|
||||||
@ -95,23 +97,28 @@ def test_import_does_not_touch_issue_tables(tmp_path):
|
|||||||
assert (count("issue"), count("issue_link")) == before
|
assert (count("issue"), count("issue_link")) == before
|
||||||
|
|
||||||
|
|
||||||
def test_missing_csv_files_give_empty_report(tmp_path):
|
def test_missing_csv_files_give_empty_report(conn, tmp_path):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
settings.ensure_defaults(conn)
|
|
||||||
rep = importer.import_package(conn, tmp_path)
|
rep = importer.import_package(conn, tmp_path)
|
||||||
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []}
|
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []}
|
||||||
|
|
||||||
|
|
||||||
def test_reversed_link_reported_separately(tmp_path):
|
def test_reversed_link_reported_separately(conn, tmp_path):
|
||||||
make_package(tmp_path)
|
make_package(tmp_path)
|
||||||
write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
|
write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
|
||||||
[["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"],
|
[["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"],
|
||||||
["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]])
|
["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]])
|
||||||
conn = db.connect(":memory:")
|
|
||||||
settings.ensure_defaults(conn)
|
|
||||||
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
|
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
|
||||||
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
|
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
|
||||||
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
|
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
|
||||||
rep = importer.import_package(conn, tmp_path)
|
rep = importer.import_package(conn, tmp_path)
|
||||||
assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}]
|
assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}]
|
||||||
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
|
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_without_key_is_skipped(tmp_path, conn):
|
||||||
|
"""M-8 #16 (T7.3 #4), спека migration «Эпик без ключа»: строка без «Ключ» пропущена, заметки new:* нет."""
|
||||||
|
write_csv(tmp_path / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"],
|
||||||
|
[["NEW-2", "", "Без ключа", "ещё не создан"], ["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"]])
|
||||||
|
rep = importer.import_package(conn, tmp_path)
|
||||||
|
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1350"]
|
||||||
|
assert [r["key"] for r in conn.execute("SELECT key FROM issue_note ORDER BY key")] == ["AURORA-1350"]
|
||||||
|
|||||||
@ -215,3 +215,24 @@ def test_fmt_agent_update_lines():
|
|||||||
assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text
|
assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text
|
||||||
text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None})
|
text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None})
|
||||||
assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text
|
assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_agent_launcher_unknown_after_restart():
|
||||||
|
"""M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст."""
|
||||||
|
base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
|
||||||
|
"warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3,
|
||||||
|
"release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"},
|
||||||
|
"update_state": None, "launcher_version": None, "update_hint": None}
|
||||||
|
text = mcp_server.fmt_agent({**base, "agent_reported": False})
|
||||||
|
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
|
||||||
|
assert "без запускателя" not in text and "агент запущен без run.py" not in text
|
||||||
|
assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True})
|
||||||
|
assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_status_right_after_hub_start(live):
|
||||||
|
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
|
||||||
|
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status()
|
||||||
|
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
|
||||||
|
text = mcp_server.agent_status()
|
||||||
|
assert "запускатель: v2" in text and "неизвестно" not in text
|
||||||
|
|||||||
@ -3,6 +3,12 @@ import pytest
|
|||||||
from jira_hub import db, mirror
|
from jira_hub import db, mirror
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(open_db):
|
||||||
|
"""Пустое зеркало в памяти; закрывается после теста."""
|
||||||
|
return open_db()
|
||||||
|
|
||||||
|
|
||||||
def issue(key, **over):
|
def issue(key, **over):
|
||||||
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
|
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
|
||||||
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
|
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
|
||||||
@ -13,8 +19,7 @@ def issue(key, **over):
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def test_apply_state_upserts_and_replaces_links():
|
def test_apply_state_upserts_and_replaces_links(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
|
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
|
||||||
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
|
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
|
||||||
assert out == {"issues": 2, "links": 1}
|
assert out == {"issues": 2, "links": 1}
|
||||||
@ -26,16 +31,14 @@ def test_apply_state_upserts_and_replaces_links():
|
|||||||
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
|
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
|
||||||
|
|
||||||
|
|
||||||
def test_links_from_other_source_survive():
|
def test_links_from_other_source_survive(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
|
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
|
||||||
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
|
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
|
||||||
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
|
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
|
||||||
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
|
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
|
||||||
|
|
||||||
|
|
||||||
def test_apply_state_marks_neighbors():
|
def test_apply_state_marks_neighbors(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
|
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
|
||||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
||||||
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
|
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
|
||||||
@ -44,8 +47,7 @@ def test_apply_state_marks_neighbors():
|
|||||||
assert mirror.known_keys(conn) == ["A-1", "B-9"]
|
assert mirror.known_keys(conn) == ["A-1", "B-9"]
|
||||||
|
|
||||||
|
|
||||||
def test_partial_load_never_lowers_neighbor():
|
def test_partial_load_never_lowers_neighbor(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
|
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
|
||||||
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
|
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
|
||||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
||||||
@ -53,8 +55,7 @@ def test_partial_load_never_lowers_neighbor():
|
|||||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
|
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_note_survives_refresh_and_renames():
|
def test_note_survives_refresh_and_renames(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
|
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
|
||||||
mirror.rename_note(conn, "new:7", "A-5")
|
mirror.rename_note(conn, "new:7", "A-5")
|
||||||
mirror.apply_state(conn, [issue("A-5")], [])
|
mirror.apply_state(conn, [issue("A-5")], [])
|
||||||
@ -65,8 +66,7 @@ def test_note_survives_refresh_and_renames():
|
|||||||
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
|
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
|
||||||
|
|
||||||
|
|
||||||
def test_list_issues_filters():
|
def test_list_issues_filters(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
|
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
|
||||||
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
|
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
|
||||||
issue("A-3", sprint="Спринт 2", duedate=None)], [])
|
issue("A-3", sprint="Спринт 2", duedate=None)], [])
|
||||||
@ -80,31 +80,27 @@ def test_list_issues_filters():
|
|||||||
assert len(mirror.list_issues(conn, None, limit=1)) == 1
|
assert len(mirror.list_issues(conn, None, limit=1)) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_same_key_keeps_note():
|
def test_rename_note_same_key_keeps_note(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("A-1")], [])
|
mirror.apply_state(conn, [issue("A-1")], [])
|
||||||
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
|
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
|
||||||
mirror.rename_note(conn, "A-1", "A-1")
|
mirror.rename_note(conn, "A-1", "A-1")
|
||||||
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
|
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_missing_is_noop():
|
def test_rename_note_missing_is_noop(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
|
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
|
||||||
assert mirror.get_note(conn, "new:404") is None
|
assert mirror.get_note(conn, "new:404") is None
|
||||||
assert mirror.get_note(conn, "A-9") is None
|
assert mirror.get_note(conn, "A-9") is None
|
||||||
|
|
||||||
|
|
||||||
def test_set_note_partial_keeps_other_fields():
|
def test_set_note_partial_keeps_other_fields(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
|
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
|
||||||
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
|
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
|
||||||
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
||||||
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
|
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_merges_with_existing():
|
def test_rename_note_merges_with_existing(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
||||||
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
|
||||||
mirror.rename_note(conn, "new:5", "A-5")
|
mirror.rename_note(conn, "new:5", "A-5")
|
||||||
@ -116,8 +112,7 @@ def test_rename_note_merges_with_existing():
|
|||||||
assert mirror.get_note(conn, "new:5") is None
|
assert mirror.get_note(conn, "new:5") is None
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_keeps_newer_real_key_stamp():
|
def test_rename_note_keeps_newer_real_key_stamp(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
|
||||||
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
|
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
|
||||||
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
|
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
|
||||||
@ -128,8 +123,7 @@ def test_rename_note_keeps_newer_real_key_stamp():
|
|||||||
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
|
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_empty_string_does_not_overwrite():
|
def test_rename_note_empty_string_does_not_overwrite(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
||||||
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
|
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
|
||||||
mirror.rename_note(conn, "new:6", "A-6")
|
mirror.rename_note(conn, "new:6", "A-6")
|
||||||
@ -151,8 +145,7 @@ def _sync_run(conn, started, finished, kind, query, status="done", count=5):
|
|||||||
(started, finished, kind, query, count, command_id))
|
(started, finished, kind, query, count, command_id))
|
||||||
|
|
||||||
|
|
||||||
def test_board_snapshot_raw_mirror_data():
|
def test_board_snapshot_raw_mirror_data(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
|
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
|
||||||
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
|
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
|
||||||
issue("AURORA-1621", epic_key="AURORA-1351")],
|
issue("AURORA-1621", epic_key="AURORA-1351")],
|
||||||
@ -171,8 +164,7 @@ def test_board_snapshot_raw_mirror_data():
|
|||||||
assert snap["last_sync"] is None and snap["generated_at"]
|
assert snap["last_sync"] is None and snap["generated_at"]
|
||||||
|
|
||||||
|
|
||||||
def test_board_last_sync_is_last_successful_full_refresh():
|
def test_board_last_sync_is_last_successful_full_refresh(conn):
|
||||||
conn = db.connect(":memory:")
|
|
||||||
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
|
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
|
||||||
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
|
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
|
||||||
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
|
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
|
||||||
@ -182,9 +174,8 @@ def test_board_last_sync_is_last_successful_full_refresh():
|
|||||||
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
|
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
|
||||||
|
|
||||||
|
|
||||||
def test_board_inside_open_transaction_keeps_it_open():
|
def test_board_inside_open_transaction_keeps_it_open(conn):
|
||||||
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
|
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
|
||||||
conn = db.connect(":memory:")
|
|
||||||
mirror.apply_state(conn, [issue("AURORA-1620")], [])
|
mirror.apply_state(conn, [issue("AURORA-1620")], [])
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
assert conn.in_transaction
|
assert conn.in_transaction
|
||||||
|
|||||||
@ -7,19 +7,20 @@ from jira_hub.queue import ClaimedError
|
|||||||
LT = {"связанные": "ASUPR_Связанные"}
|
LT = {"связанные": "ASUPR_Связанные"}
|
||||||
|
|
||||||
|
|
||||||
def fresh():
|
@pytest.fixture
|
||||||
conn = db.connect(":memory:")
|
def conn(open_db):
|
||||||
settings.ensure_defaults(conn)
|
"""База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста."""
|
||||||
settings.set_(conn, "link_types", LT)
|
c = open_db()
|
||||||
return conn
|
settings.ensure_defaults(c)
|
||||||
|
settings.set_(c, "link_types", LT)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
def fields(key, **extra):
|
def fields(key, **extra):
|
||||||
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
|
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
|
||||||
|
|
||||||
|
|
||||||
def test_create_batch_adds_tail_refresh_and_estimate():
|
def test_create_batch_adds_tail_refresh_and_estimate(conn):
|
||||||
conn = fresh()
|
|
||||||
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
|
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
|
||||||
view = queue.batch_view(conn, b["id"])
|
view = queue.batch_view(conn, b["id"])
|
||||||
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
|
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
|
||||||
@ -28,16 +29,14 @@ def test_create_batch_adds_tail_refresh_and_estimate():
|
|||||||
assert view["status"] == "queued" and view["author"] == "claude"
|
assert view["status"] == "queued" and view["author"] == "claude"
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_only_batch_has_no_tail():
|
def test_refresh_only_batch_has_no_tail(conn):
|
||||||
conn = fresh()
|
|
||||||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||||||
assert b["count"] == 1
|
assert b["count"] == 1
|
||||||
with pytest.raises(CommandError):
|
with pytest.raises(CommandError):
|
||||||
queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
|
queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
|
||||||
|
|
||||||
|
|
||||||
def test_claim_order_pause_and_report():
|
def test_claim_order_pause_and_report(conn):
|
||||||
conn = fresh()
|
|
||||||
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
|
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
|
||||||
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
|
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
|
||||||
c = queue.claim(conn, agent_version=1, settings_version=1)
|
c = queue.claim(conn, agent_version=1, settings_version=1)
|
||||||
@ -61,13 +60,13 @@ def test_claim_order_pause_and_report():
|
|||||||
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
|
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_is_exclusive(tmp_path):
|
def test_claim_is_exclusive(tmp_path, open_db):
|
||||||
import threading
|
import threading
|
||||||
path = tmp_path / "hub.sqlite"
|
path = tmp_path / "hub.sqlite"
|
||||||
setup = db.connect(path)
|
setup = open_db(path)
|
||||||
settings.ensure_defaults(setup)
|
settings.ensure_defaults(setup)
|
||||||
queue.create_batch(setup, "x", "claude", [fields("A-1")])
|
queue.create_batch(setup, "x", "claude", [fields("A-1")])
|
||||||
conns = [db.connect(path), db.connect(path)]
|
conns = [open_db(path), open_db(path)]
|
||||||
barrier, results = threading.Barrier(2), []
|
barrier, results = threading.Barrier(2), []
|
||||||
|
|
||||||
def worker(c):
|
def worker(c):
|
||||||
@ -83,8 +82,7 @@ def test_claim_is_exclusive(tmp_path):
|
|||||||
assert row["n"] == 1 and row["d"] == len(got)
|
assert row["n"] == 1 and row["d"] == len(got)
|
||||||
|
|
||||||
|
|
||||||
def test_backup_failure_does_not_block_claim(caplog):
|
def test_backup_failure_does_not_block_claim(conn, caplog):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
|
|
||||||
def broken(label):
|
def broken(label):
|
||||||
@ -96,8 +94,7 @@ def test_backup_failure_does_not_block_claim(caplog):
|
|||||||
assert "не создана" in caplog.text
|
assert "не создана" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_temp_key_substitution_and_skip():
|
def test_temp_key_substitution_and_skip(conn):
|
||||||
conn = fresh()
|
|
||||||
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
|
||||||
queue.create_batch(conn, "создать", "claude", [
|
queue.create_batch(conn, "создать", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
||||||
@ -120,8 +117,7 @@ def test_temp_key_substitution_and_skip():
|
|||||||
assert view["commands"][3]["error"] == "задача new:61 не создана"
|
assert view["commands"][3]["error"] == "задача new:61 не создана"
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_and_retry():
|
def test_cancel_and_retry(conn):
|
||||||
conn = fresh()
|
|
||||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
with pytest.raises(ClaimedError):
|
with pytest.raises(ClaimedError):
|
||||||
@ -139,16 +135,14 @@ def test_cancel_and_retry():
|
|||||||
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
|
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_report_after_force_cancel_keeps_cancelled():
|
def test_report_after_force_cancel_keeps_cancelled(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
queue.cancel_command(conn, c["id"], force=True)
|
queue.cancel_command(conn, c["id"], force=True)
|
||||||
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
|
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_confirms_by_state():
|
def test_reconcile_confirms_by_state(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
@ -160,8 +154,7 @@ def test_reconcile_confirms_by_state():
|
|||||||
assert queue.batch_view(conn, b["id"])["status"] == "done"
|
assert queue.batch_view(conn, b["id"])["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
def test_auto_refresh_conditions():
|
def test_auto_refresh_conditions(conn):
|
||||||
conn = fresh()
|
|
||||||
assert queue.maybe_auto_refresh(conn) is None # агент не на связи
|
assert queue.maybe_auto_refresh(conn) is None # агент не на связи
|
||||||
queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста
|
queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста
|
||||||
b = queue.maybe_auto_refresh(conn)
|
b = queue.maybe_auto_refresh(conn)
|
||||||
@ -177,8 +170,7 @@ def test_auto_refresh_conditions():
|
|||||||
assert queue.maybe_auto_refresh(conn) is None
|
assert queue.maybe_auto_refresh(conn) is None
|
||||||
|
|
||||||
|
|
||||||
def test_backup_called_for_first_write_only(tmp_path):
|
def test_backup_called_for_first_write_only(conn, tmp_path):
|
||||||
conn = fresh()
|
|
||||||
calls = []
|
calls = []
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||||
c1 = queue.claim(conn, backup=calls.append)
|
c1 = queue.claim(conn, backup=calls.append)
|
||||||
@ -187,28 +179,27 @@ def test_backup_called_for_first_write_only(tmp_path):
|
|||||||
assert calls == [f"batch-{c1['batch_id']}"]
|
assert calls == [f"batch-{c1['batch_id']}"]
|
||||||
|
|
||||||
|
|
||||||
def test_no_backup_for_refresh_batch():
|
def test_no_backup_for_refresh_batch(conn):
|
||||||
conn = fresh()
|
|
||||||
calls = []
|
calls = []
|
||||||
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||||||
queue.claim(conn, backup=calls.append)
|
queue.claim(conn, backup=calls.append)
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_make_backup_rotates(tmp_path):
|
def test_make_backup_rotates(tmp_path, open_db):
|
||||||
from jira_hub import backup
|
from jira_hub import backup
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
|
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
|
||||||
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
|
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
|
||||||
assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
|
assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
|
||||||
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_rotation_keeps_schema_migration_copy(tmp_path):
|
def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
|
||||||
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
|
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
|
||||||
from jira_hub import backup
|
from jira_hub import backup
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
|
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
|
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
|
||||||
@ -224,9 +215,8 @@ def _claimed_ensure_issue(conn, summary="Тема"):
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
|
def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn):
|
||||||
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
|
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
|
||||||
conn = fresh()
|
|
||||||
c = _claimed_ensure_issue(conn)
|
c = _claimed_ensure_issue(conn)
|
||||||
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
||||||
created = db.utcnow().isoformat()
|
created = db.utcnow().isoformat()
|
||||||
@ -236,9 +226,8 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
|
|||||||
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None
|
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
|
def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch):
|
||||||
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
|
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
|
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
|
||||||
@ -264,8 +253,7 @@ def _two_batches_with_dependent(conn):
|
|||||||
return a, b, first
|
return a, b, first
|
||||||
|
|
||||||
|
|
||||||
def test_redelivered_definer_blocks_dependents():
|
def test_redelivered_definer_blocks_dependents(conn):
|
||||||
conn = fresh()
|
|
||||||
a, b, first = _two_batches_with_dependent(conn)
|
a, b, first = _two_batches_with_dependent(conn)
|
||||||
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
|
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
|
||||||
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
|
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
|
||||||
@ -273,8 +261,7 @@ def test_redelivered_definer_blocks_dependents():
|
|||||||
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
|
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
|
||||||
|
|
||||||
|
|
||||||
def test_auto_refresh_waits_for_claimed_without_report():
|
def test_auto_refresh_waits_for_claimed_without_report(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.claim(conn, agent_version=1, settings_version=1) # агент на связи
|
queue.claim(conn, agent_version=1, settings_version=1) # агент на связи
|
||||||
queue.create_batch(conn, "y", "claude", [fields("A-3")])
|
queue.create_batch(conn, "y", "claude", [fields("A-3")])
|
||||||
queue.claim(conn) # выдана, отчёта нет
|
queue.claim(conn) # выдана, отчёта нет
|
||||||
@ -283,8 +270,7 @@ def test_auto_refresh_waits_for_claimed_without_report():
|
|||||||
assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята
|
assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята
|
||||||
|
|
||||||
|
|
||||||
def test_redelivery_of_lost_claim_before_queue():
|
def test_redelivery_of_lost_claim_before_queue(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
again = queue.claim(conn)
|
again = queue.claim(conn)
|
||||||
@ -297,8 +283,7 @@ def test_redelivery_of_lost_claim_before_queue():
|
|||||||
assert queue.agent_view(conn)["redelivered"] == []
|
assert queue.agent_view(conn)["redelivered"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_redelivery_limit_fails_command():
|
def test_redelivery_limit_fails_command(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
queue.claim(conn)
|
queue.claim(conn)
|
||||||
@ -310,8 +295,7 @@ def test_redelivery_limit_fails_command():
|
|||||||
assert queue.MAX_DELIVERIES == 3
|
assert queue.MAX_DELIVERIES == 3
|
||||||
|
|
||||||
|
|
||||||
def test_redelivery_oldest_first():
|
def test_redelivery_oldest_first(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
queue.create_batch(conn, "y", "claude", [fields("A-2")])
|
queue.create_batch(conn, "y", "claude", [fields("A-2")])
|
||||||
rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall()
|
rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall()
|
||||||
@ -320,16 +304,14 @@ def test_redelivery_oldest_first():
|
|||||||
assert queue.claim(conn)["id"] == rows[1]["id"]
|
assert queue.claim(conn)["id"] == rows[1]["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_redelivered_refresh_keeps_one_sync_run():
|
def test_redelivered_refresh_keeps_one_sync_run(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
r = queue.claim(conn)
|
r = queue.claim(conn)
|
||||||
assert queue.claim(conn)["id"] == r["id"]
|
assert queue.claim(conn)["id"] == r["id"]
|
||||||
assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1
|
assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_redelivery_does_not_repeat_backup():
|
def test_redelivery_does_not_repeat_backup(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
labels = []
|
labels = []
|
||||||
queue.claim(conn, backup=labels.append)
|
queue.claim(conn, backup=labels.append)
|
||||||
@ -337,9 +319,8 @@ def test_redelivery_does_not_repeat_backup():
|
|||||||
assert len(labels) == 1
|
assert len(labels) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_migrated_claimed_is_redelivered():
|
def test_migrated_claimed_is_redelivered(conn):
|
||||||
"""Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2."""
|
"""Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2."""
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
||||||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,))
|
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,))
|
||||||
@ -347,8 +328,7 @@ def test_migrated_claimed_is_redelivered():
|
|||||||
assert queue.get_command(conn, cid)["deliveries"] == 2
|
assert queue.get_command(conn, cid)["deliveries"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_report_for_queued_command_rejected():
|
def test_report_for_queued_command_rejected(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
||||||
with pytest.raises(queue.NotClaimedError):
|
with pytest.raises(queue.NotClaimedError):
|
||||||
@ -356,8 +336,7 @@ def test_report_for_queued_command_rejected():
|
|||||||
assert queue.get_command(conn, cid)["status"] == "queued"
|
assert queue.get_command(conn, cid)["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
def test_late_report_after_force_cancel_keeps_issue_key():
|
def test_late_report_after_force_cancel_keeps_issue_key(conn):
|
||||||
conn = fresh()
|
|
||||||
mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import")
|
||||||
queue.create_batch(conn, "x", "claude", [
|
queue.create_batch(conn, "x", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||||||
@ -371,8 +350,7 @@ def test_late_report_after_force_cancel_keeps_issue_key():
|
|||||||
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
|
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
|
||||||
|
|
||||||
|
|
||||||
def test_late_failed_report_after_force_cancel_changes_nothing():
|
def test_late_failed_report_after_force_cancel_changes_nothing(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [
|
queue.create_batch(conn, "x", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
@ -388,16 +366,14 @@ def _fail_by_limit(conn, cid):
|
|||||||
assert queue.get_command(conn, cid)["status"] == "failed"
|
assert queue.get_command(conn, cid)["status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
def test_error_is_trimmed_to_1500():
|
def test_error_is_trimmed_to_1500(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
|
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
|
||||||
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
|
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
|
||||||
|
|
||||||
|
|
||||||
def test_late_done_after_delivery_limit_keeps_issue_key():
|
def test_late_done_after_delivery_limit_keeps_issue_key(conn):
|
||||||
conn = fresh()
|
|
||||||
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
|
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
|
||||||
queue.create_batch(conn, "x", "claude", [
|
queue.create_batch(conn, "x", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
||||||
@ -411,8 +387,7 @@ def test_late_done_after_delivery_limit_keeps_issue_key():
|
|||||||
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
||||||
|
|
||||||
|
|
||||||
def test_cancelled_batch_with_done_command_is_cancelled():
|
def test_cancelled_batch_with_done_command_is_cancelled(conn):
|
||||||
conn = fresh()
|
|
||||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
|
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
|
||||||
first = queue.claim(conn)
|
first = queue.claim(conn)
|
||||||
queue.report_result(conn, first["id"], "done", result={"changed": []})
|
queue.report_result(conn, first["id"], "done", result={"changed": []})
|
||||||
@ -424,8 +399,7 @@ def test_cancelled_batch_with_done_command_is_cancelled():
|
|||||||
assert view["counts"]["done"] == 2
|
assert view["counts"]["done"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_empty_full_refresh_keeps_neighbors():
|
def test_empty_full_refresh_keeps_neighbors(conn):
|
||||||
conn = fresh()
|
|
||||||
jql = settings.get(conn, "base_jql")
|
jql = settings.get(conn, "base_jql")
|
||||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
||||||
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
||||||
@ -435,8 +409,7 @@ def test_empty_full_refresh_keeps_neighbors():
|
|||||||
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_finished_batch_stays_done():
|
def test_cancel_finished_batch_stays_done(conn):
|
||||||
conn = fresh()
|
|
||||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
while (c := queue.claim(conn)):
|
while (c := queue.claim(conn)):
|
||||||
queue.report_result(conn, c["id"], "done", result={"changed": []})
|
queue.report_result(conn, c["id"], "done", result={"changed": []})
|
||||||
@ -445,8 +418,7 @@ def test_cancel_finished_batch_stays_done():
|
|||||||
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None
|
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_redelivered_full_refresh_marks_neighbors_once():
|
def test_redelivered_full_refresh_marks_neighbors_once(conn):
|
||||||
conn = fresh()
|
|
||||||
jql = settings.get(conn, "base_jql")
|
jql = settings.get(conn, "base_jql")
|
||||||
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
|
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
|
||||||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||||||
@ -463,8 +435,7 @@ def test_redelivered_full_refresh_marks_neighbors_once():
|
|||||||
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
|
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_redelivered_refresh_resets_counts():
|
def test_redelivered_refresh_resets_counts(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
r = queue.claim(conn)
|
r = queue.claim(conn)
|
||||||
queue.record_state(conn, r["id"], 150, 40)
|
queue.record_state(conn, r["id"], 150, 40)
|
||||||
@ -476,8 +447,7 @@ def test_redelivered_refresh_resets_counts():
|
|||||||
assert run["started_at"] == started
|
assert run["started_at"] == started
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_failed_by_limit_finishes_sync_run():
|
def test_refresh_failed_by_limit_finishes_sync_run(conn):
|
||||||
conn = fresh()
|
|
||||||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||||
r = queue.claim(conn)
|
r = queue.claim(conn)
|
||||||
for _ in range(queue.MAX_DELIVERIES):
|
for _ in range(queue.MAX_DELIVERIES):
|
||||||
@ -486,10 +456,62 @@ def test_refresh_failed_by_limit_finishes_sync_run():
|
|||||||
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
|
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_batch_reports_still_claimed():
|
def test_cancel_batch_reports_still_claimed(conn):
|
||||||
conn = fresh()
|
|
||||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
view = queue.cancel_batch(conn, b["id"])
|
view = queue.cancel_batch(conn, b["id"])
|
||||||
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
|
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
|
||||||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependent_skipped_when_creation_cancelled(conn):
|
||||||
|
"""O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено»."""
|
||||||
|
b = queue.create_batch(conn, "x", "claude", [
|
||||||
|
{"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}},
|
||||||
|
{"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}])
|
||||||
|
c = queue.claim(conn)
|
||||||
|
queue.cancel_command(conn, c["id"], force=True)
|
||||||
|
assert queue.claim(conn) is None # комментарий и хвостовой refresh пропущены
|
||||||
|
cmds = queue.batch_view(conn, b["id"])["commands"]
|
||||||
|
assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"]
|
||||||
|
assert cmds[1]["error"] == "создание задачи new:901 отменено"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependent_on_failed_and_cancelled_names_cancelled(conn):
|
||||||
|
"""Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому."""
|
||||||
|
b = queue.create_batch(conn, "x", "claude", [
|
||||||
|
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}},
|
||||||
|
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}},
|
||||||
|
{"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}])
|
||||||
|
first = queue.claim(conn)
|
||||||
|
queue.report_result(conn, first["id"], "failed", error="HTTP 400")
|
||||||
|
second = queue.claim(conn)
|
||||||
|
queue.cancel_command(conn, second["id"], force=True)
|
||||||
|
queue.claim(conn)
|
||||||
|
link = queue.batch_view(conn, b["id"])["commands"][2]
|
||||||
|
assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_confirms_fields_with_crlf(conn):
|
||||||
|
"""O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию."""
|
||||||
|
queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}])
|
||||||
|
c = queue.claim(conn)
|
||||||
|
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], [])
|
||||||
|
assert queue.reconcile(conn) == [c["id"]]
|
||||||
|
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_limit_of_write_command_keeps_open_sync_run(conn):
|
||||||
|
"""T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh)."""
|
||||||
|
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||||
|
c = queue.claim(conn)
|
||||||
|
r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}])
|
||||||
|
rid = queue.batch_view(conn, r["id"])["commands"][0]["id"]
|
||||||
|
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 "
|
||||||
|
"WHERE id = ?", (rid,))
|
||||||
|
conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid))
|
||||||
|
for _ in range(queue.MAX_DELIVERIES - 1):
|
||||||
|
assert queue.claim(conn)["id"] == c["id"]
|
||||||
|
assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh
|
||||||
|
assert queue.get_command(conn, c["id"])["status"] == "failed"
|
||||||
|
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None
|
||||||
|
|||||||
@ -365,7 +365,8 @@ def test_vanished_tree_keeps_work(env, capsys):
|
|||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
r.step()
|
r.step()
|
||||||
assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
|
assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
|
||||||
assert "удалён незавершённый запрос" not in capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out # minor-3 #16
|
||||||
|
|
||||||
|
|
||||||
def test_each_dir_cleaned_once(env, tmp_path, capsys):
|
def test_each_dir_cleaned_once(env, tmp_path, capsys):
|
||||||
@ -402,3 +403,21 @@ def test_relay_targets_serve_host(tmp_path, monkeypatch):
|
|||||||
stop.set()
|
stop.set()
|
||||||
srv.server_close()
|
srv.server_close()
|
||||||
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
|
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys):
|
||||||
|
"""minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз."""
|
||||||
|
r, root, h = env["relay"], env["root"], env["hub"]
|
||||||
|
r.step() # root вычищен
|
||||||
|
link = tmp_path / "bridge-link"
|
||||||
|
link.symlink_to(root, target_is_directory=True)
|
||||||
|
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается
|
||||||
|
conn = h.conn()
|
||||||
|
settings.set_(conn, "relay_dir", str(link))
|
||||||
|
conn.close()
|
||||||
|
r._settings_at = None
|
||||||
|
capsys.readouterr()
|
||||||
|
r.step()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert (root / "work" / "busy.json").exists()
|
||||||
|
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -101,3 +103,185 @@ def test_hint():
|
|||||||
def test_hint_none_when_no_agent():
|
def test_hint_none_when_no_agent():
|
||||||
rel = {"version": 6, "sha256": "s", "published_at": "t"}
|
rel = {"version": 6, "sha256": "s", "published_at": "t"}
|
||||||
assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None
|
assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- hub-hygiene-d: публикация при сбое записи, проверка манифеста (решения 1–6) ---------------------------------
|
||||||
|
|
||||||
|
def _fail_replace_into(monkeypatch, *names):
|
||||||
|
"""os.replace в файл с одним из имён names падает «диск полон»; остальные переименования работают."""
|
||||||
|
real = os.replace
|
||||||
|
|
||||||
|
def fake(src, dst, *a, **kw):
|
||||||
|
if Path(dst).name in names:
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
return real(src, dst, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(release.os, "replace", fake)
|
||||||
|
|
||||||
|
|
||||||
|
def _listing(rd):
|
||||||
|
return {p.name: p.read_bytes() for p in rd.iterdir()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_success_leaves_no_prev(tmp_path):
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 5))
|
||||||
|
release.publish(rd, agent_file(tmp_path, 6))
|
||||||
|
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
assert release.current(rd)["version"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_write_failure_keeps_previous(tmp_path, monkeypatch):
|
||||||
|
"""M-1 #25, спека «Диск полон при записи манифеста»: прежние v8 agent.py и манифест, ни .tmp, ни agent.py.prev."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 8))
|
||||||
|
before = _listing(rd)
|
||||||
|
_fail_replace_into(monkeypatch, "manifest.json")
|
||||||
|
with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: .*No space left on device"):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 9, body="print('v9')\n"))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert _listing(rd) == before
|
||||||
|
assert release.load(rd)["version"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_write_failure_keeps_previous(tmp_path, monkeypatch):
|
||||||
|
"""M-1, M-6 #25: сбой fsync при записи agent.py — временный файл удалён, прежний agent.py на месте."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 8))
|
||||||
|
before = _listing(rd)
|
||||||
|
|
||||||
|
def no_space(fd):
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
|
monkeypatch.setattr(release.os, "fsync", no_space)
|
||||||
|
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 9))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert _listing(rd) == before
|
||||||
|
assert release.load(rd)["version"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_publish_failure_leaves_nothing(tmp_path, monkeypatch):
|
||||||
|
"""Review Focus 3: прежней публикации нет — после сбоя release/ пуст, а не с agent.py без манифеста."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
_fail_replace_into(monkeypatch, "manifest.json")
|
||||||
|
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 1))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert list(rd.iterdir()) == [] and release.current(rd) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_prev_removed_by_next_publish(tmp_path):
|
||||||
|
"""Review Focus 1: agent.py.prev, оставленный упавшим процессом, убирает следующая успешная публикация."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 6))
|
||||||
|
(rd / "agent.py").rename(rd / "agent.py.prev") # процесс упал между переименованием и записью
|
||||||
|
assert release.load(rd) is None
|
||||||
|
release.publish(rd, agent_file(tmp_path, 7))
|
||||||
|
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
assert release.load(rd)["version"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog):
|
||||||
|
"""Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 8))
|
||||||
|
before = _listing(rd)
|
||||||
|
new_src = agent_file(tmp_path, 9)
|
||||||
|
_fail_replace_into(monkeypatch, "manifest.json")
|
||||||
|
real = release.os.replace
|
||||||
|
|
||||||
|
def fake(src, dst, *a, **kw):
|
||||||
|
if Path(src).name == "agent.py.prev":
|
||||||
|
raise OSError(13, "Permission denied")
|
||||||
|
return real(src, dst, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(release.os, "replace", fake)
|
||||||
|
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||||
|
release.publish(rd, new_src)
|
||||||
|
assert "прежняя публикация не восстановлена" in caplog.text
|
||||||
|
monkeypatch.undo()
|
||||||
|
after = _listing(rd) # задокументированное состояние: новый agent.py, старый agent.py.prev, старый манифест
|
||||||
|
assert sorted(after) == ["agent.py", "agent.py.prev", "manifest.json"]
|
||||||
|
assert after["agent.py"] == new_src.read_bytes()
|
||||||
|
assert after["agent.py.prev"] == before["agent.py"]
|
||||||
|
assert after["manifest.json"] == before["manifest.json"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("manifest", [
|
||||||
|
{"version": 6.9, "sha256": "a" * 64}, {"version": True, "sha256": "a" * 64}, {"version": "7", "sha256": "a" * 64},
|
||||||
|
{"version": 0, "sha256": "a" * 64}, {"version": 6, "sha256": "a" * 63}, {"version": 6, "sha256": "A" * 64},
|
||||||
|
{"version": 6}, [6, "a" * 64], None,
|
||||||
|
])
|
||||||
|
def test_invalid_manifest_means_no_release(tmp_path, manifest):
|
||||||
|
"""M-7 #25, решение 4: version — целое (не bool) ≥ 1, sha256 — 64 символа [0-9a-f], корень — объект."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
rd.mkdir()
|
||||||
|
(rd / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
assert release.current(rd) is None and release.load(rd) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog):
|
||||||
|
"""M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память."""
|
||||||
|
monkeypatch.setattr(release, "_warned", None)
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
rd.mkdir()
|
||||||
|
manifest = rd / "manifest.json"
|
||||||
|
manifest.write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
|
||||||
|
for _ in range(100):
|
||||||
|
assert release.current(rd) is None
|
||||||
|
warnings = lambda: [r for r in caplog.records if "манифест" in r.getMessage()]
|
||||||
|
assert len(warnings()) == 1 and "6.9" in warnings()[0].getMessage()
|
||||||
|
manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") # другая ошибка — ещё одно
|
||||||
|
release.current(rd)
|
||||||
|
release.current(rd)
|
||||||
|
assert len(warnings()) == 2
|
||||||
|
manifest.write_text('{"version": 6, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") # годный сбрасывает память
|
||||||
|
assert release.current(rd)["version"] == 6
|
||||||
|
manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8")
|
||||||
|
release.current(rd)
|
||||||
|
assert len(warnings()) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog):
|
||||||
|
"""M-5 #25, решение 6: sha256 сошёлся, но файл не в UTF-8 — публикации нет, ошибка в логе."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 6))
|
||||||
|
data = b"AGENT_VERSION = 6\n\xff\xfe\n"
|
||||||
|
(rd / "agent.py").write_bytes(data)
|
||||||
|
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
m["sha256"] = hashlib.sha256(data).hexdigest()
|
||||||
|
(rd / "manifest.json").write_text(json.dumps(m), encoding="utf-8")
|
||||||
|
assert release.current(rd)["version"] == 6
|
||||||
|
assert release.load(rd) is None
|
||||||
|
assert "agent.py не в UTF-8" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_failure_keeps_previous_agent(tmp_path, monkeypatch):
|
||||||
|
"""T2-I1: сбой переименования agent.py -> agent.py.prev при существующем agent.py — прежний agent.py не трогаем."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
release.publish(rd, agent_file(tmp_path, 8))
|
||||||
|
before = _listing(rd)
|
||||||
|
real = release.os.replace
|
||||||
|
|
||||||
|
def fake(src, dst, *a, **kw):
|
||||||
|
if Path(dst).name == "agent.py.prev":
|
||||||
|
raise OSError(13, "Permission denied")
|
||||||
|
return real(src, dst, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(release.os, "replace", fake)
|
||||||
|
with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: "):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 9))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert _listing(rd) == before
|
||||||
|
assert release.load(rd)["version"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_hint_unknown_launcher_until_reported():
|
||||||
|
"""M-2 #25, решение 7: пока после запуска хаба не было claim, «без run.py» не утверждается; откат — по update_state."""
|
||||||
|
rel = {"version": 10, "sha256": "s", "published_at": "t"}
|
||||||
|
online9 = {"agent_version": "9", "online": True}
|
||||||
|
assert release.hint(rel, online9, None, None, reported=False) is None
|
||||||
|
assert release.hint(rel, online9, None, None, reported=True) == "агент запущен без run.py — обновится только вручную"
|
||||||
|
rb = {"state": "rolled_back", "from_version": 9, "to_version": 10, "error": "код 1"}
|
||||||
|
assert release.hint(rel, online9, rb, None, reported=False) == "откат с v10 на v9: код 1"
|
||||||
|
|||||||
@ -3,19 +3,20 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from jira_hub import db, settings
|
from jira_hub import settings
|
||||||
|
|
||||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||||
|
|
||||||
|
|
||||||
def fresh():
|
@pytest.fixture
|
||||||
conn = db.connect(":memory:")
|
def conn(open_db):
|
||||||
settings.ensure_defaults(conn)
|
"""База в памяти с настройками по умолчанию; закрывается после теста."""
|
||||||
return conn
|
c = open_db()
|
||||||
|
settings.ensure_defaults(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_and_tokens():
|
def test_defaults_and_tokens(conn):
|
||||||
conn = fresh()
|
|
||||||
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA"
|
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA"
|
||||||
assert settings.get(conn, "pacing")["ensure_fields"] == 120
|
assert settings.get(conn, "pacing")["ensure_fields"] == 120
|
||||||
tok = settings.tokens(conn)
|
tok = settings.tokens(conn)
|
||||||
@ -23,8 +24,7 @@ def test_defaults_and_tokens():
|
|||||||
assert "tokens" not in settings.get_all(conn)
|
assert "tokens" not in settings.get_all(conn)
|
||||||
|
|
||||||
|
|
||||||
def test_set_bumps_version():
|
def test_set_bumps_version(conn):
|
||||||
conn = fresh()
|
|
||||||
v0 = settings.get(conn, "settings_version")
|
v0 = settings.get(conn, "settings_version")
|
||||||
v1 = settings.set_(conn, "pacing", {"ensure_fields": 5})
|
v1 = settings.set_(conn, "pacing", {"ensure_fields": 5})
|
||||||
assert v1 == v0 + 1
|
assert v1 == v0 + 1
|
||||||
@ -32,14 +32,12 @@ def test_set_bumps_version():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"])
|
@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"])
|
||||||
def test_protected_keys(key):
|
def test_protected_keys(conn, key):
|
||||||
conn = fresh()
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
settings.set_(conn, key, "x")
|
settings.set_(conn, key, "x")
|
||||||
|
|
||||||
|
|
||||||
def test_init_from_config():
|
def test_init_from_config(conn):
|
||||||
conn = fresh()
|
|
||||||
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
keys = settings.init_from_config(conn, cfg)
|
keys = settings.init_from_config(conn, cfg)
|
||||||
assert "transitions_map" in keys
|
assert "transitions_map" in keys
|
||||||
@ -55,8 +53,8 @@ def test_init_from_config():
|
|||||||
settings.init_from_config(conn, cfg, force=True)
|
settings.init_from_config(conn, cfg, force=True)
|
||||||
|
|
||||||
|
|
||||||
def test_relay_dir_default_and_validation(tmp_path):
|
def test_relay_dir_default_and_validation(tmp_path, open_db):
|
||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = open_db(tmp_path / "hub.sqlite")
|
||||||
settings.ensure_defaults(conn)
|
settings.ensure_defaults(conn)
|
||||||
assert settings.get(conn, "relay_dir") is None
|
assert settings.get(conn, "relay_dir") is None
|
||||||
settings.validate("relay_dir", None)
|
settings.validate("relay_dir", None)
|
||||||
@ -65,4 +63,3 @@ def test_relay_dir_default_and_validation(tmp_path):
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
settings.validate("relay_dir", bad)
|
settings.validate("relay_dir", bad)
|
||||||
assert "relay_dir" not in settings.AGENT_KEYS
|
assert "relay_dir" not in settings.AGENT_KEYS
|
||||||
conn.close()
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user