From 3a11c618a6cc17780221d72db10853bf256e0d23 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 20:45:10 +0400 Subject: [PATCH 01/37] =?UTF-8?q?spec:=20bridge-core=20=E2=80=94=20=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=20Jira=20(=D1=85=D0=B0=D0=B1,=20=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4,=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82,=20MCP,=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/bridge-core/.openspec.yaml | 2 + openspec/changes/bridge-core/design.md | 66 +++++++++++++++ openspec/changes/bridge-core/proposal.md | 39 +++++++++ .../changes/bridge-core/specs/agent/spec.md | 62 ++++++++++++++ .../bridge-core/specs/commands/spec.md | 81 +++++++++++++++++++ .../bridge-core/specs/data-model/spec.md | 67 +++++++++++++++ .../changes/bridge-core/specs/hub-api/spec.md | 70 ++++++++++++++++ .../changes/bridge-core/specs/mcp/spec.md | 36 +++++++++ .../bridge-core/specs/migration/spec.md | 32 ++++++++ 9 files changed, 455 insertions(+) create mode 100644 openspec/changes/bridge-core/.openspec.yaml create mode 100644 openspec/changes/bridge-core/design.md create mode 100644 openspec/changes/bridge-core/proposal.md create mode 100644 openspec/changes/bridge-core/specs/agent/spec.md create mode 100644 openspec/changes/bridge-core/specs/commands/spec.md create mode 100644 openspec/changes/bridge-core/specs/data-model/spec.md create mode 100644 openspec/changes/bridge-core/specs/hub-api/spec.md create mode 100644 openspec/changes/bridge-core/specs/mcp/spec.md create mode 100644 openspec/changes/bridge-core/specs/migration/spec.md diff --git a/openspec/changes/bridge-core/.openspec.yaml b/openspec/changes/bridge-core/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/bridge-core/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md new file mode 100644 index 0000000..3f9ec4c --- /dev/null +++ b/openspec/changes/bridge-core/design.md @@ -0,0 +1,66 @@ +## Context + +Две машины в одной локальной сети: Mac mini (`192.168.0.106`, Python 3.13, uv, Docker не всегда запущен) и виртуалка +с macOS и системным Python 3.9.6 (`192.168.0.112`, входящих портов нет, `pip3` есть, доступ к Jira банка +`https://sdlc.go.rshbank.ru/jira` по логину/паролю из `config.json`). Общая папка гипервизора — запасной канал. +Исходный код для переноса: `admin/jira-sync/jira_sync.py` (клиент REST v2 + Agile, темп `Pacer`, карта переходов, +исправленная семантика связей: в POST `/issueLink` `inwardIssue` — источник, в GET `outwardIssue` у X — цель X), +`config.example.json` (id полей, типы, карта переходов, типы связей, доска 4216). + +## Goals / Non-Goals + +Goals: убрать ручной перенос файлов; один источник ключей; журнал и повторяемость изменений; безопасный повтор любой +команды; темп, при котором Jira не отвечает 429; MCP, через который сессия ставит пакеты и читает состояние. +Non-goals — см. proposal.md. + +## Decisions + +1. **Два репозитория, один change.** Спека и план лежат в `jira-hub`; `jira-agent` ссылается на них из CLAUDE.md. + Агент меняется редко и живёт на другой машине, поэтому отдельный репозиторий без зависимостей от хаба. +2. **Хаб — один процесс: SQLite + HTTP + MCP.** Один писатель в базу, нет зависимости от Docker, база — один файл. + Агент и интерфейсы ходят только через HTTP; виртуалке ничего не ставить, у неё нет входящих портов. +3. **Команды — желаемый результат** (`ensure_*`), каждая на одной задаче, выполняется как «прочитать из Jira → если результат + уже есть, `done` без записи → иначе записать». Повтор безопасен по построению. +4. **Хаб не повторяет команды.** Взятая команда без ответа видна как «нет ответа от агента с …»; повторяет её только сам + агент после перезапуска по своему журналу, и только через проверку. Отменить взятую команду может только человек. +5. **Истина в Jira.** После каждого пакета хаб добавляет `refresh` по затронутым ключам; команда без ответа, результат + которой виден в зеркале, помечается `confirmed_by_state`. +6. **Политика темпа в хабе, пауза на команде.** При выдаче команды хаб ставит «не раньше, чем через N с после предыдущей + операции в Jira» по типу команды и типу предыдущей. Агент помнит только время последнего вызова Jira. Нет следующей + команды — нет паузы. 429/503 — Retry-After в агенте (инструкция сервера, не политика). +7. **Пакет = одно действие пользователя.** Команды пакета — по порядку, пакеты — по порядку постановки, одна команда в полёте. + Упавшая команда не останавливает пакет; команды, ссылающиеся на её результат, — `skipped`. +8. **Временные ключи `new:`** живут в хабе: `ensure_issue` с временным ключом, после `done` хаб подменяет `new:` + в параметрах следующих команд пакета и в `issue_note`. У агента соответствия нет. +9. **Зеркало — JQL из настроек** (`project = AURORA AND labels = AWPCA`); задачи, на которые ведут связи, но вне JQL, + дочитываются по ключу и помечаются `neighbor`. +10. **Служебные заметки отдельно от зеркала** (`issue_note`), чтобы обновление из Jira их не затирало. +11. **Настройки агента приходят от хаба** (id полей, карта переходов, типы связей, политика темпа, версия настроек); + локально у агента — адрес хаба, токен, Jira и учётные данные. + +## Risks / Trade-offs + +- Чтение перед каждой записью — лишний вызов на команду. Цена мала (пауза перед чтением 2 с), а гарантия идемпотентности + стоит дороже. +- Поиск дубля при `ensure_issue` по точной теме и автору за 7 дней может не найти задачу, если тему успели поправить + в Jira руками. Остаточный риск принят: окно между ответом Jira и записью журнала — миллисекунды. +- Один агент и одна команда в полёте — медленно для сотни операций (как и сейчас: ~4,5 ч на полную актуализацию). + Это цена темпа, не архитектуры. +- Хаб должен быть запущен для MCP; если нет — инструменты возвращают понятную ошибку, не падают. +- SQLite по сети не используется; при переезде хаба на другую машину — переносится файл. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-11 | Хаб: SQLite в одном процессе с HTTP, без PostgreSQL | один писатель, Docker здесь не всегда поднят, виртуалке ничего не ставить; владелец отдал плумбинг исполнителю | смена хранилища — замена одного модуля хаба, API не меняется | +| 2026-10-11 | HTTP-библиотека хаба — stdlib `http.server` + `ThreadingHTTPServer`, без FastAPI | API маленький, зависимостей ноль, тесты через `urllib`; MCP — пакет `mcp` (FastMCP) через uv | при росте API — переезд на FastAPI внутри того же репозитория | +| 2026-10-11 | Порт хаба 8790, привязка к `0.0.0.0` | свободный порт; виртуалка в LAN с другим адресом, `127.0.0.1` ей недоступен | пересечение портов — поправить настройку | +| 2026-10-11 | `claim` — долгий запрос до 30 с, затем сразу следующий | минимальная задержка выдачи без постоянного трафика | — | +| 2026-10-11 | Поиск дубля в `ensure_issue`: `project = AURORA AND reporter = currentUser() AND created >= -7d` + точное сравнение темы на клиенте | JQL `summary ~` нечёткий; точное сравнение в агенте надёжнее | дубль при смене темы руками в первые 7 дней; хаб показывает такие команды отдельно | +| 2026-10-11 | Исполнитель входит в `ensure_fields` | 11.10 исполнители ставились руками, в просмотрщике это нужная операция; владелец согласился | — | +| 2026-10-11 | Паузы по умолчанию: изменение задачи 120 с, комментарий и переход 30 с, чтение 2 с, создание связи 30 с | значения `pacing` из `config.example.json`, проверенные на Jira банка 10–11.10 | настройки, правятся без кода | +| 2026-10-11 | Резервная копия файла базы перед каждым пакетом в `backups/`, хранить последние 30 | заменяет историю версий зеркала | место на диске; число в настройках | +| 2026-10-11 | Хранение базы и настроек хаба — `~/.local/share/jira-hub/` | вне git, переживает пересоздание worktree | — | +| 2026-10-11 | Регистрация MCP — в `~/.claude.json` для проекта `awp-ca` (stdio, `uv run jira-hub mcp`) | как остальные MCP этого проекта | — | +| 2026-10-11 | Полная синхронизация по расписанию: хаб ставит `refresh` по базовой JQL раз в 60 мин, если агент на связи и очередь пуста | свежее зеркало без участия человека | лишние чтения; интервал в настройках, 0 отключает | diff --git a/openspec/changes/bridge-core/proposal.md b/openspec/changes/bridge-core/proposal.md new file mode 100644 index 0000000..0cf4c8c --- /dev/null +++ b/openspec/changes/bridge-core/proposal.md @@ -0,0 +1,39 @@ +## Why + +Актуализация Jira-бэклога АРМ КА (проект AURORA, метка AWPCA) ведётся из этой машины, а доступ к Jira банка есть только +у виртуалки (`192.168.0.112`). Рабочий цикл 11.10.2026: правка CSV и скрипта здесь → владелец копирует файлы в виртуалку +через общую папку → запускает `jira_sync.py` → выгружает фильтр в CSV → копирует обратно → сверка. За вечер пять кругов, +один закончился дублем задачи (AURORA-1588): ключи созданных задач жили в `new_tasks.csv` виртуалки, перенос в +неправильную сторону создал задачу повторно. Владелец больше не хочет быть курьером, а CSV как источник истины себя +исчерпал. + +Решения владельца 11.10.2026 (сессия brainstorming, `admin/jira-sync/next-session-bridge.md`): +- единственный источник истины по задачам проекта — база на этой машине (Mac mini, `192.168.0.106`); виртуалка — клиент; +- интерфейсы к базе — просмотрщик (UI, позже) и MCP (сейчас); CSV и `jira_sync.py` как CLI уходят; +- модель изменений — гибрид: зеркало задач только для чтения, любая правка порождает команду в очереди (журнал, повторяемость); +- темп запросов к Jira — требование первого порядка (429), политика темпа живёт в хабе, не в агенте; +- хаб никогда не повторяет команды сам; каждая команда — желаемый результат, выполняется как «проверь, потом сделай»; +- зеркало ограничено JQL `project = AURORA AND labels = AWPCA` (полный набор задач нашего проекта; AURORA шире), фильтры — + на стороне приложения; +- первая поставка — только ядро (база, хаб, агент, импорт, MCP); просмотрщик на живых данных и правки из UI — отдельные change'и; +- по репозиторию на компонент: `jira-hub` (эта машина) и `jira-agent` (виртуалка), ремоуты — приватные в Gitea под `aep`. + +## What Changes + +- **jira-hub** (новый репозиторий, Python 3.13, uv): SQLite-база с зеркалом задач, служебными заметками, пакетами и + командами; HTTP API в локальной сети для агента и интерфейсов; MCP-сервер (stdio) как второй вход в тот же код; + разовый импорт CSV-пакета `admin/jira-sync/`. +- **jira-agent** (новый репозиторий, Python 3.9, только stdlib): один файл `agent.py` + `agent.json`; опрашивает хаб, + выполняет команды в Jira по правилу «проверь, потом сделай», ведёт локальный журнал, возвращает состояние задач. +- Спеки: `data-model`, `commands`, `agent`, `hub-api`, `mcp`, `migration` (все новые). +- `admin/jira-sync/README.md` получает пометку «заменено мостом» (правка вне репозиториев, по чеклисту закрытия); + `awp-ca/CLAUDE.md` — две строки в таблице репозиториев. + +## Non-goals + +- Просмотрщик `sprint-2-links.html` на живых данных и правки из него (отдельные change'и: `viewer-live`, `viewer-edit`). +- Автоматическая подстройка темпа по фактам 429 (хаб только показывает их). +- История версий зеркала (резервные копии файла базы перед пакетом заменяют её). +- Работа с типом «Ошибка» вне базовой JQL, несколько проектов Jira, несколько агентов. +- TLS и аутентификация пользователей в хабе: домашняя сеть, два статических токена. +- Произвольные JQL на запись, произвольные вызовы REST из очереди. diff --git a/openspec/changes/bridge-core/specs/agent/spec.md b/openspec/changes/bridge-core/specs/agent/spec.md new file mode 100644 index 0000000..45e9d3a --- /dev/null +++ b/openspec/changes/bridge-core/specs/agent/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Состав и настройки агента +Агент SHALL состоять из одного файла `agent.py` (Python ≥ 3.9, только стандартная библиотека) и файла `agent.json`: +`hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` или `bearer` с `token`), `verify_ssl`, +`ca_bundle`, `timeout`, `poll_seconds`. Всё остальное (id полей, типы, карта переходов, типы связей, доска, темп) агент +SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT отправляться в хаб +и MUST NOT попадать в журнал и логи. + +#### Scenario: Проверка без записи +- **WHEN** `python3 agent.py check` +- **THEN** агент печатает доступность хаба (версия настроек) и Jira (`GET /myself`), в Jira ничего не пишет + +### Requirement: Цикл выполнения +Агент SHALL работать одним процессом: `claim` к хабу (долгий запрос) → при получении команды: досыпать до +`pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись +«finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте. При изменении +`settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения команды. + +#### Scenario: Остановка между командами +- **WHEN** получен Ctrl-C во время выполнения команды +- **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается + +### Requirement: Журнал на диске и восстановление +Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `started {command_id, at}`, `finished {command_id, +status, result, error, jira_calls, rate_limited}`, `acked {command_id}`. При старте агент SHALL: отправить хабу все +`finished` без `acked`; для `started` без `finished` — выполнить команду заново по правилу «проверь, потом сделай» +(запросив её параметры у хаба по id). Команда `python3 agent.py prune` SHALL удалять из журнала записи с `acked` старше +30 дней. + +#### Scenario: Падение после ответа Jira +- **WHEN** агент упал между `POST /issue` и записью `finished`, затем перезапущен +- **THEN** агент находит в журнале `started` без `finished`, запрашивает команду у хаба, находит созданную задачу поиском + дубля и отчитывается `done` с её ключом; второй задачи нет + +### Requirement: Ограничение темпа и 429 +Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. +На HTTP 429 и 503 агент SHALL ждать `Retry-After` (иначе 60 с × номер попытки) и повторять тот же запрос до 5 раз, +считая попытки в `rate_limited`; после 5 неудач — `failed`. Пауза между записями внутри одной команды — по зазору типа команды. + +#### Scenario: 429 с Retry-After +- **WHEN** Jira ответила 429 с `Retry-After: 90` +- **THEN** агент ждёт 90 с, повторяет тот же запрос; в отчёте `rate_limited = 1` + +### Requirement: Чтение состояния +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks` и спринт, +`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет +в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` +и отправить с признаком `neighbor`. + +#### Scenario: Полная синхронизация +- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах +- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` + +### Requirement: Доставка и запуск +README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json`, +`check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, `prune`. Версия агента SHALL быть в +константе файла и в каждом отчёте; хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек. + +#### Scenario: Устаревший агент +- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` +- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему diff --git a/openspec/changes/bridge-core/specs/commands/spec.md b/openspec/changes/bridge-core/specs/commands/spec.md new file mode 100644 index 0000000..3db48a4 --- /dev/null +++ b/openspec/changes/bridge-core/specs/commands/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `GET /issue/{key}` (поле спринта), список спринтов доски из `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `refresh` | `keys` (список) или `jql` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб | + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +### Requirement: Временные ключи и ссылки внутри пакета +Параметр-ключ любой команды MAY быть временным `new:`. Хаб SHALL выдавать агенту такую команду только после `done` +команды `ensure_issue` с тем же `temp_key` в том же пакете, подставив настоящий ключ. Если `ensure_issue` завершилась +`failed` или `cancelled`, зависимые команды SHALL получить `skipped`. Временный ключ вне пакета с его `ensure_issue` +SHALL отвергаться при создании пакета (422). + +#### Scenario: Создать и привязать +- **WHEN** пакет: `ensure_issue {temp_key: new:60, …}`, `ensure_link {source: new:60, target: AURORA-1537, …}` +- **THEN** вторая команда выдаётся агенту с `source = AURORA-1590` после завершения первой + +### Requirement: Пакет и порядок выполнения +Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка +и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Очередь SHALL +выдавать команды в порядке `(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; +`claimed` команда MUST NOT отменяться автоматически. + +#### Scenario: Оценка длительности +- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment` +- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh` + +### Requirement: Политика темпа и пауза на команде +Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и +`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30, `refresh` 2). При выдаче команды +хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего +последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент +SHALL выдерживать зазор типа команды между записями. + +#### Scenario: Нет следующей команды — нет паузы +- **WHEN** очередь пуста +- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira до 1500 символов), пакет продолжается. Команда в +`claimed` без ответа SHALL показываться как «нет ответа от агента с ». Если после любого `refresh` желаемый +результат такой команды виден в зеркале, хаб SHALL перевести её в `confirmed_by_state`. Автоматических повторов хаб +MUST NOT делать; повтор — новая команда с теми же параметрами по запросу интерфейса. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed` 20 минут, агент перезапущен, `refresh` показал X в «Спринт 2» +- **THEN** команда `confirmed_by_state`, следующая команда очереди выдаётся diff --git a/openspec/changes/bridge-core/specs/data-model/spec.md b/openspec/changes/bridge-core/specs/data-model/spec.md new file mode 100644 index 0000000..8d4b4ac --- /dev/null +++ b/openspec/changes/bridge-core/specs/data-model/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_note`, `batch`, `command`, +`sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии +в `setting`. Денежных и дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени). + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая + +### Requirement: Зеркало задачи +Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), +`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), +`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` +SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается +на `target` (у `source` в GET это `outwardIssue`). Обновлять `issue` и `issue_link` SHALL только загрузка состояния от +агента; HTTP API интерфейсов и MCP MUST NOT писать в них. + +#### Scenario: Загрузка состояния заменяет связи +- **WHEN** агент прислал состояние задачи X с двумя связями +- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные + со стороны X, не трогаются до загрузки их источника + +#### Scenario: Соседняя задача +- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL +- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` + +### Requirement: Служебные заметки +Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), +`fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала +MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL переименовать строку заметок +на настоящий ключ. + +#### Scenario: Заметка переживает refresh +- **WHEN** у задачи есть `issue_note.fact_status = "В работе"` и приходит полное обновление зеркала +- **THEN** `fact_status` не изменился + +### Requirement: Пакеты и команды +Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`. +Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status` +(`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at`, `finished_at`, `result` (JSON), +`error`, `jira_calls`, `rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, +чей результат подставляется). Статус пакета SHALL вычисляться из команд, не храниться. + +#### Scenario: Статус пакета +- **WHEN** в пакете 3 команды: `done`, `claimed`, `queued` +- **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped` + +### Requirement: Журнал синхронизаций и статус агента +Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`, +`issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. Таблица `agent_status` (одна строка) SHALL +хранить `last_seen_at`, `agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. + +#### Scenario: Агент давно не выходил на связь +- **WHEN** `last_seen_at` старше 5 минут +- **THEN** `agent_status` через API и MCP отдаётся с признаком `offline` + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `board_id`, `sprint_names`, `tokens` (агентский и клиентский), `settings_version` +(увеличивается при любой правке), `backup_keep`, `auto_refresh_minutes`. Начальные значения SHALL импортироваться из +`admin/jira-sync/config.example.json` командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию diff --git a/openspec/changes/bridge-core/specs/hub-api/spec.md b/openspec/changes/bridge-core/specs/hub-api/spec.md new file mode 100644 index 0000000..7077863 --- /dev/null +++ b/openspec/changes/bridge-core/specs/hub-api/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Процесс и доступ +`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в +`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты SHALL требовать `Authorization: Bearer `: +агентский токен открывает только `/agent/*`, клиентский — всё остальное. Ответы — JSON, ошибки — `{error, detail}` с +кодами 400/401/404/409/422. + +#### Scenario: Чужой токен +- **WHEN** агентским токеном вызван `GET /issues` +- **THEN** 401 + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at}` — долгий запрос до 30 с; ответ `{command: + {id, type, params, pause_seconds}, settings_version}` или `{command: null, settings_version}`; обновляет `agent_status`. +- `GET /agent/settings` — настройки для агента (без токенов). +- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала). +- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный + отчёт по команде в терминальном статусе отвечает 200 без изменений. +- `POST /agent/state` `{command_id, issues: [...], links: [...], page, last_page}` — загрузка состояния пачками; + хаб обновляет зеркало и `sync_run`. +- `GET /agent/known-keys` — ключи зеркала (для определения соседей). + +#### Scenario: Выдача с подстановкой ключа +- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` +- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах + +#### Scenario: Повторный отчёт +- **WHEN** агент дважды прислал `result` для команды 15 +- **THEN** второй ответ 200, статус и результат не изменились + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `limit`/`offset` — компактные карточки (ключ, тема, статус, приоритет, срок, исполнитель, спринт, + эпик, заметки кратко). +- `GET /issues/{key}` — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней. +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных + параметрах, временном ключе без `ensure_issue`. +- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel`. +- `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`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, но статус не меняется + +### Requirement: Резервные копии +Перед выдачей первой команды пакета, содержащего записи в Jira, хаб SHALL скопировать файл базы в `backups/<дата-время>-.sqlite` +и хранить последние `backup_keep` копий. + +#### Scenario: Ротация +- **WHEN** копий больше `backup_keep` +- **THEN** самые старые удаляются до `backup_keep` + +### Requirement: Автообновление зеркала +Если `auto_refresh_minutes > 0`, агент на связи и очередь пуста дольше этого интервала, хаб SHALL поставить пакет +«Автообновление» из `refresh {jql: base_jql}`. + +#### Scenario: Очередь занята +- **WHEN** интервал прошёл, но в очереди есть `queued` команды +- **THEN** автообновление не ставится до опустошения очереди diff --git a/openspec/changes/bridge-core/specs/mcp/spec.md b/openspec/changes/bridge-core/specs/mcp/spec.md new file mode 100644 index 0000000..e4e401b --- /dev/null +++ b/openspec/changes/bridge-core/specs/mcp/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Сервер и регистрация +`jira-hub mcp` SHALL запускать MCP-сервер по stdio (пакет `mcp`), ходящий в HTTP API хаба клиентским токеном из +настроек. Регистрация — в `~/.claude.json` для проекта `awp-ca` как `jira-hub` (`uv run --project <путь> jira-hub mcp`). +Если хаб недоступен, каждый инструмент SHALL вернуть текст «хаб не запущен: jira-hub serve», а не исключение. + +#### Scenario: Хаб выключен +- **WHEN** вызван `issues` при остановленном хабе +- **THEN** ответ инструмента — одна строка с подсказкой, сервер MCP продолжает работать + +### 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`, `batch_cancel` | `id` | новый пакет / результат отмены | +| `refresh` | `keys` или `full`, `wait` (bool) | `sync_run` или id пакета | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, 429 за сутки, версия настроек | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Автор команд из 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 минут со статусами всех команд diff --git a/openspec/changes/bridge-core/specs/migration/spec.md b/openspec/changes/bridge-core/specs/migration/spec.md new file mode 100644 index 0000000..50b51c0 --- /dev/null +++ b/openspec/changes/bridge-core/specs/migration/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Инициализация настроек +`jira-hub init --config ` SHALL перенести в `setting`: `project_key`, типы задач, +`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `status_labels`, `kind_labels`, `comment_marker`, +`pacing` (в новую форму по типам команд), `transitions.map`, `link_types`, `sprints.board_id`; сгенерировать два токена; +установить `base_jql = "project = AURORA AND labels = AWPCA"`. Поля `auth`, `base_url`, `verify_ssl`, `ca_bundle` +MUST NOT переноситься (они у агента). + +#### Scenario: Повторный init +- **WHEN** `init` вызван на уже инициализированной базе +- **THEN** настройки не перезаписываются без `--force`, токены сохраняются + +### Requirement: Импорт CSV-пакета +`jira-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить +`issue_note` (ключ Jira; для строк без ключа — `new:<номер строки>`): статус по факту, вид работы, что сделано, что +осталось, внутренние колонки. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи +против зеркала после первого `refresh`), не как команды. Импорт идемпотентен: повтор обновляет заметки, не дублирует. + +#### Scenario: Импорт и первая синхронизация +- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён +- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи + из CSV, которых нет в зеркале, и связи из `links.csv`, которых нет в `issue_link` + +### Requirement: Что остаётся в admin/ +После ввода моста `admin/jira-sync/README.md` SHALL получить в начале пометку «заменено мостом jira-hub/jira-agent, +CSV заморожены <дата>»; `jira_sync.py`, CSV, `runs/`, `summary.md`, `_generator/` остаются как история без изменений. +Артефакт просмотрщика на claude.ai MUST NOT переопубликовываться. + +#### Scenario: Пометка в README +- **WHEN** мост запущен и первая синхронизация сверена с CSV +- **THEN** README пакета начинается с пометки и ссылки на репозитории моста -- 2.47.2 From 41a5ce9e5baa57771537bddc5ccb3a73696a42ee Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 20:45:42 +0400 Subject: [PATCH 02/37] =?UTF-8?q?spec:=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8E=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B8=D0=BF=D0=B0=D0=BC=20=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/bridge-core/specs/commands/spec.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openspec/changes/bridge-core/specs/commands/spec.md b/openspec/changes/bridge-core/specs/commands/spec.md index 3db48a4..dfa3fef 100644 --- a/openspec/changes/bridge-core/specs/commands/spec.md +++ b/openspec/changes/bridge-core/specs/commands/spec.md @@ -73,7 +73,10 @@ SHALL выдерживать зазор типа команды между за ### Requirement: Ошибки и подтверждение по состоянию Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira до 1500 символов), пакет продолжается. Команда в `claimed` без ответа SHALL показываться как «нет ответа от агента с ». Если после любого `refresh` желаемый -результат такой команды виден в зеркале, хаб SHALL перевести её в `confirmed_by_state`. Автоматических повторов хаб +результат такой команды виден в зеркале (для `ensure_issue` — задача с той же темой от нас; `ensure_fields` — все поля +совпадают; `ensure_status` — статус равен последнему в пути; `ensure_link` — связь есть и конфликтов нет; `ensure_sprint` — +спринт совпадает), хаб SHALL перевести её в `confirmed_by_state`. Для `ensure_comment` и `refresh` подтверждения по +состоянию нет: комментарии в зеркале не хранятся, такая команда ждёт отчёта агента или отмены человеком. Автоматических повторов хаб MUST NOT делать; повтор — новая команда с теми же параметрами по запросу интерфейса. #### Scenario: Отчёт потерян, факт на месте -- 2.47.2 From 464f5ec62b1e70b5ef2c9f2a763bb0edadfe15a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:35:21 +0400 Subject: [PATCH 03/37] =?UTF-8?q?plan:=20bridge-core=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=85=D0=B0=D0=B1=D0=B0=20(10=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-11-bridge-core-hub.md | 2927 +++++++++++++++++ 1 file changed, 2927 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-11-bridge-core-hub.md diff --git a/docs/superpowers/plans/2026-10-11-bridge-core-hub.md b/docs/superpowers/plans/2026-10-11-bridge-core-hub.md new file mode 100644 index 0000000..022ad15 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-bridge-core-hub.md @@ -0,0 +1,2927 @@ +# Мост Jira — хаб (jira-hub) — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Хаб на Mac mini: SQLite-зеркало задач Jira, очередь команд-«желаемых результатов», HTTP API для агента и интерфейсов, MCP для сессии Claude, разовый импорт CSV-пакета. + +**Architecture:** Один процесс `jira-hub serve`: `http.server` (ThreadingHTTPServer) поверх модулей `db` → `settings` / `mirror` / `commands` / `queue` → `api`; каждый запрос открывает своё соединение SQLite (WAL, busy_timeout). `jira-hub mcp` — FastMCP по stdio, ходит в тот же HTTP API клиентским токеном. Хаб никогда не повторяет команды, не знает пароль Jira и не пишет в зеркало иначе как по состоянию от агента. + +**Tech Stack:** Python 3.13, uv, stdlib `sqlite3` + `http.server`, пакет `mcp` (FastMCP), pytest. + +**Spec:** `openspec/changes/bridge-core/` (proposal.md, design.md, specs/{data-model,commands,agent,hub-api,mcp,migration}/spec.md). План агента — в репозитории `jira-agent`: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. + +## Global Constraints + +- Python `>=3.13`, зависимости только `mcp` (runtime) и `pytest` (dev); HTTP — stdlib, без FastAPI. +- База — один файл SQLite в `~/.local/share/jira-hub/hub.sqlite` (переопределение `JIRA_HUB_HOME`), WAL, `timeout=10`. +- Все маршруты под `Authorization: Bearer `; агентский токен — только `/agent/*`, клиентский — всё остальное; ошибки — JSON `{error, detail}`. +- Зеркало (`issue`, `issue_link`) пишет только загрузка состояния от агента; `issue_note` обновление зеркала не трогает. +- Хаб не повторяет команды сам; взятая команда без ответа видна как «нет ответа от агента с ». +- Статусы команды: `queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`; статус пакета вычисляется. +- Паузы по умолчанию (`pacing`): `ensure_issue`, `ensure_fields` 120; `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30; `refresh` 2. +- Временные ключи `new:` живут только в хабе; команде с временным ключом ставится `depends_on` на `ensure_issue` того же пакета. +- Учётные данные Jira (`auth`, `base_url`, `verify_ssl`, `ca_bundle`) в настройках хаба отсутствуют. +- Язык кода и комментариев — русский, идентификаторы — английские; коммиты — русские, с `Co-Authored-By: Claude Fable 5.1 `. +- Автор команд из MCP — `claude`, из API без автора — `api`, автообновления — `hub`. + +## Review Focus + +1. Параллельные `claim` от двух процессов агента (владелец случайно запустил второй): должна выдаться одна команда, вторая — `null`. Тест — Task 5 (`test_claim_is_exclusive`). +2. Отчёт агента по команде, которую человек отменил с `force` пока она была `claimed`: статус остаётся `cancelled`, результат не затирается. Тест — Task 5 (`test_report_after_force_cancel_keeps_cancelled`). +3. Состояние от агента с ключами, которых нет в базовой JQL и которые не помечены как соседи, — не должны стать «нашими» молча; `neighbor` выставляется по списку из запроса. Тест — Task 3 (`test_apply_state_marks_neighbors`). +4. Пакет из одного `refresh` не получает хвостового `refresh` и не делает резервную копию. Тест — Task 5 (`test_refresh_only_batch_has_no_tail`) и Task 6 (`test_no_backup_for_refresh_batch`). +5. `PUT /settings` с ключом `tokens` или `settings_version` — 422, версия не меняется. Тест — Task 7 (`test_settings_put_rejects_protected`). + +## Структура файлов + +| Файл | Ответственность | +|---|---| +| `pyproject.toml` | пакет `jira_hub`, скрипт `jira-hub`, зависимости | +| `src/jira_hub/paths.py` | каталог данных, путь к базе, каталог копий | +| `src/jira_hub/db.py` | соединение, схема, миграция, транзакция | +| `src/jira_hub/settings.py` | настройки, токены, `init` из `config.example.json`, вид для агента | +| `src/jira_hub/mirror.py` | зеркало: загрузка состояния, заметки, выборки | +| `src/jira_hub/commands.py` | контракт команд: валидация, ключи, подстановка, оценка, подтверждение по состоянию | +| `src/jira_hub/queue.py` | пакеты, выдача, отчёты, отмена, повтор, сверка, автообновление | +| `src/jira_hub/backup.py` | резервные копии базы | +| `src/jira_hub/api.py` | HTTP: маршруты, токены, долгий `claim`, фоновое автообновление | +| `src/jira_hub/importer.py` | импорт CSV-пакета `admin/jira-sync` | +| `src/jira_hub/client.py` | HTTP-клиент хаба (для MCP и тестов) | +| `src/jira_hub/mcp_server.py` | инструменты MCP | +| `src/jira_hub/cli.py` | `init`, `serve`, `import`, `mcp`, `tokens` | +| `tests/` | pytest; `tests/fixtures/config.example.json` — копия из `admin/jira-sync` | + +--- + +### Task 1: Скелет пакета и схема базы + +**Files:** +- Create: `pyproject.toml`, `src/jira_hub/__init__.py`, `src/jira_hub/paths.py`, `src/jira_hub/db.py` +- Test: `tests/test_db.py` + +**Interfaces:** +- Produces: `paths.home() -> Path`, `paths.db_path() -> Path`, `paths.backups_dir() -> Path`; + `db.connect(path) -> sqlite3.Connection` (row_factory=Row, autocommit, схема применена), + `db.transaction(conn)` — контекстный менеджер `BEGIN IMMEDIATE … COMMIT/ROLLBACK`, `db.now() -> str` (ISO UTC, секунды). + +- [ ] **Step 1: pyproject и пакет** + +`pyproject.toml`: +```toml +[project] +name = "jira-hub" +version = "0.1.0" +description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" +requires-python = ">=3.13" +dependencies = ["mcp>=1.2"] + +[project.scripts] +jira-hub = "jira_hub.cli:main" + +[dependency-groups] +dev = ["pytest>=8"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/jira_hub"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +``` + +`src/jira_hub/__init__.py`: +```python +"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP.""" +__version__ = "0.1.0" +``` + +`src/jira_hub/paths.py`: +```python +"""Где лежат данные хаба: каталог, база, резервные копии.""" +import os +from pathlib import Path + + +def home() -> Path: + return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub"))) + + +def db_path() -> Path: + return home() / "hub.sqlite" + + +def backups_dir() -> Path: + return home() / "backups" +``` + +Выполнить: `uv sync` (создаст `.venv` и `uv.lock`). Добавить в `.gitignore` строки `.venv/`, `__pycache__/`, `*.sqlite*`. + +- [ ] **Step 2: Тест схемы** + +`tests/test_db.py`: +```python +from jira_hub import db + + +def test_connect_creates_schema(): + conn = db.connect(":memory:") + 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 conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "1" + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + + +def test_connect_is_idempotent(tmp_path): + path = tmp_path / "hub.sqlite" + db.connect(path).close() + conn = db.connect(path) + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + + +def test_transaction_rolls_back_on_error(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + try: + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") + raise RuntimeError("boom") + except RuntimeError: + pass + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 +``` + +- [ ] **Step 3: Запустить — убедиться, что падает** + +Run: `uv run pytest tests/test_db.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.db'` + +- [ ] **Step 4: Реализация db.py** + +`src/jira_hub/db.py`: +```python +"""Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version.""" +import json +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +SCHEMA_VERSION = 1 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS setting ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS issue ( + key TEXT PRIMARY KEY, + id TEXT, + issue_type TEXT, + summary TEXT, + description TEXT, + status TEXT, + priority TEXT, + assignee TEXT, + reporter TEXT, + duedate TEXT, + planned_start TEXT, + planned_end TEXT, + epic_key TEXT, + labels TEXT NOT NULL DEFAULT '[]', + sprint TEXT, + created TEXT, + jira_updated TEXT, + fetched_at TEXT NOT NULL, + neighbor INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS issue_link ( + source_key TEXT NOT NULL, + target_key TEXT NOT NULL, + link_type TEXT NOT NULL, + PRIMARY KEY (source_key, target_key, link_type) +); +CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); +CREATE TABLE IF NOT EXISTS issue_note ( + key TEXT PRIMARY KEY, + fact_status TEXT, + work_kind TEXT, + done_text TEXT, + rest_text TEXT, + owner_note TEXT, + updated_at TEXT, + updated_by TEXT +); +CREATE TABLE IF NOT EXISTS batch ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT NOT NULL, + created_at TEXT NOT NULL, + cancelled_at TEXT, + estimate_seconds INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS command ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES batch(id), + seq INTEGER NOT NULL, + type TEXT NOT NULL, + params TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + claimed_at TEXT, + finished_at TEXT, + result TEXT, + error TEXT, + jira_calls INTEGER NOT NULL DEFAULT 0, + rate_limited INTEGER NOT NULL DEFAULT 0, + pause_seconds INTEGER, + depends_on INTEGER REFERENCES command(id) +); +CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq); +CREATE TABLE IF NOT EXISTS sync_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + kind TEXT NOT NULL, + query TEXT, + issues_count INTEGER NOT NULL DEFAULT 0, + links_count INTEGER NOT NULL DEFAULT 0, + jira_calls INTEGER NOT NULL DEFAULT 0, + rate_limited INTEGER NOT NULL DEFAULT 0, + command_id INTEGER +); +CREATE TABLE IF NOT EXISTS agent_status ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_seen_at TEXT, + agent_version TEXT, + settings_version INTEGER, + current_command_id INTEGER, + last_jira_call_at TEXT +); +""" + + +def now() -> str: + """Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе.""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def connect(path) -> sqlite3.Connection: + """Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных + операций — transaction().""" + is_memory = str(path) == ":memory:" + if not is_memory: + Path(path).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False) + conn.row_factory = sqlite3.Row + if not is_memory: + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA foreign_keys = ON") + migrate(conn) + return conn + + +def migrate(conn: sqlite3.Connection) -> None: + conn.executescript(SCHEMA) + row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone() + if row is None: + conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) + conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") + + +@contextmanager +def transaction(conn: sqlite3.Connection): + """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).""" + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except BaseException: + conn.execute("ROLLBACK") + raise + else: + conn.execute("COMMIT") +``` + +- [ ] **Step 5: Запустить — зелёный** + +Run: `uv run pytest tests/test_db.py -v` +Expected: 3 passed + +- [ ] **Step 6: Коммит** + +```bash +git add pyproject.toml uv.lock .gitignore src/jira_hub tests/test_db.py +git commit -m "feat(hub): скелет пакета, схема SQLite, транзакции + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: Настройки и `init` из config.example.json + +**Files:** +- Create: `src/jira_hub/settings.py`, `tests/fixtures/config.example.json` (копия `../../admin/jira-sync/config.example.json` — скопировать командой `cp`) +- Test: `tests/test_settings.py` + +**Interfaces:** +- Produces: `settings.ensure_defaults(conn)`, `settings.get(conn, key, default=None)`, `settings.get_all(conn, hide_tokens=True) -> dict`, + `settings.set_(conn, key, value, bump=True) -> int` (новая `settings_version`; `ValueError` для защищённых ключей), + `settings.for_agent(conn) -> dict`, `settings.tokens(conn) -> {"agent": str, "client": str}`, + `settings.init_from_config(conn, cfg: dict, force=False) -> list[str]`, `settings.pacing(conn) -> dict`, + константы `DEFAULT_PACING`, `AGENT_KEYS`, `PROTECTED`. + +- [ ] **Step 1: Тесты** + +`tests/test_settings.py`: +```python +import json +from pathlib import Path + +import pytest + +from jira_hub import db, settings + +FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" + + +def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + return conn + + +def test_defaults_and_tokens(): + conn = fresh() + assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" + assert settings.get(conn, "pacing")["ensure_fields"] == 120 + tok = settings.tokens(conn) + assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"] + assert "tokens" not in settings.get_all(conn) + + +def test_set_bumps_version(): + conn = fresh() + v0 = settings.get(conn, "settings_version") + v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) + assert v1 == v0 + 1 + assert settings.get(conn, "pacing") == {"ensure_fields": 5} + + +@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"]) +def test_protected_keys(key): + conn = fresh() + with pytest.raises(ValueError): + settings.set_(conn, key, "x") + + +def test_init_from_config(): + conn = fresh() + cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) + keys = settings.init_from_config(conn, cfg) + assert "transitions_map" in keys + assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" + assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} + assert settings.get(conn, "board_id") == 4216 + assert settings.get(conn, "pacing") == settings.DEFAULT_PACING + agent_view = settings.for_agent(conn) + assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view + assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные" + with pytest.raises(RuntimeError): + settings.init_from_config(conn, cfg) + settings.init_from_config(conn, cfg, force=True) +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `mkdir -p tests/fixtures && cp ../../../admin/jira-sync/config.example.json tests/fixtures/ && uv run pytest tests/test_settings.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.settings'` + +- [ ] **Step 3: Реализация** + +`src/jira_hub/settings.py`: +```python +"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version, +агент перечитывает настройки, когда видит новую версию в ответе claim.""" +import json +import secrets + +DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, + "ensure_link": 30, "ensure_sprint": 30, "refresh": 2} + +DEFAULTS = { + "base_jql": "project = AURORA AND labels = AWPCA", + "project_key": "AURORA", + "pacing": DEFAULT_PACING, + "backup_keep": 30, + "auto_refresh_minutes": 60, + "min_agent_version": 1, + "port": 8790, + "settings_version": 1, + "initialized": False, + "issue_types": {}, + "epic_name_field_id": None, + "field_ids": {}, + "epic_link": {}, + "priority_map": {}, + "priority_value_key": "name", + "labels": [], + "status_labels": {}, + "kind_labels": {}, + "comment_marker": "", + "transitions_map": {}, + "link_types": {}, + "board_id": None, +} + +# Что получает агент (без токенов и служебных ключей хаба) +AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", + "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", + "transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"] + +PROTECTED = {"settings_version", "schema_version", "tokens"} + + +def ensure_defaults(conn) -> None: + for key, value in DEFAULTS.items(): + conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) + if get(conn, "tokens") is None: + conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)", + (_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),)) + + +def _dump(value) -> str: + return json.dumps(value, ensure_ascii=False) + + +def get(conn, key, default=None): + row = conn.execute("SELECT value FROM setting WHERE key = ?", (key,)).fetchone() + return default if row is None else json.loads(row["value"]) + + +def get_all(conn, hide_tokens=True) -> dict: + out = {r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM setting ORDER BY key")} + if hide_tokens: + out.pop("tokens", None) + return out + + +def set_(conn, key, value, bump=True) -> int: + """Записать настройку; bump=True поднимает settings_version. Защищённые ключи снаружи не меняются.""" + if key in PROTECTED: + raise ValueError(f"настройка «{key}» не редактируется") + conn.execute("INSERT INTO setting(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, _dump(value))) + if bump: + version = get(conn, "settings_version", 1) + 1 + conn.execute("UPDATE setting SET value = ? WHERE key = 'settings_version'", (_dump(version),)) + return version + return get(conn, "settings_version", 1) + + +def for_agent(conn) -> dict: + return {key: get(conn, key) for key in AGENT_KEYS} + + +def tokens(conn) -> dict: + return get(conn, "tokens") + + +def pacing(conn) -> dict: + return {**DEFAULT_PACING, **(get(conn, "pacing") or {})} + + +def pacing_from_config(p: dict) -> dict: + """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" + issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) + return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, + "ensure_link": comment, "ensure_sprint": comment, "refresh": 2} + + +def init_from_config(conn, cfg: dict, force=False) -> list[str]: + """Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira + (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают.""" + if get(conn, "initialized") and not force: + raise RuntimeError("хаб уже инициализирован; повторный init — с --force") + project = cfg["project_key"] + values = { + "project_key": project, + "base_jql": f"project = {project} AND labels = AWPCA", + "issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")}, + "epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}}, + "epic_name_field_id": cfg.get("epic_name_field_id"), + "field_ids": cfg.get("fields", {}), + "epic_link": cfg.get("epic_link", {}), + "priority_map": cfg.get("priority_map", {}), + "priority_value_key": cfg.get("priority_value_key", "name"), + "labels": cfg.get("labels", []), + "status_labels": cfg.get("status_labels", {}), + "kind_labels": cfg.get("kind_labels", {}), + "comment_marker": cfg.get("comment_marker", ""), + "transitions_map": cfg.get("transitions", {}).get("map", {}), + "link_types": cfg.get("link_types", {}), + "board_id": cfg.get("sprints", {}).get("board_id"), + "pacing": pacing_from_config(cfg.get("pacing", {})), + } + for key, value in values.items(): + set_(conn, key, value, bump=False) + set_(conn, "initialized", True) + return sorted(values) +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `uv run pytest tests/test_settings.py -v` +Expected: 6 passed + +- [ ] **Step 5: Коммит** + +```bash +git add src/jira_hub/settings.py tests/test_settings.py tests/fixtures/config.example.json +git commit -m "feat(hub): настройки, токены, init из config.example.json + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: Зеркало задач и заметки + +**Files:** +- Create: `src/jira_hub/mirror.py` +- Test: `tests/test_mirror.py` + +**Interfaces:** +- Consumes: `db.connect`, `db.transaction`, `db.now`. +- Produces: `mirror.ISSUE_COLUMNS`, `mirror.NOTE_FIELDS`; + `mirror.apply_state(conn, issues: list[dict], links: list[dict], neighbor_keys=(), fetched_at=None) -> {"issues": n, "links": m}` + (issue: словарь с ключами `ISSUE_COLUMNS`, `labels` — список; link: `{source_key, target_key, link_type}`); + `mirror.known_keys(conn) -> list[str]`; `mirror.list_issues(conn, filters: dict | None, limit=50, offset=0) -> list[dict]` + (фильтры `sprint, assignee, status, epic, work_kind, fact_status, label, neighbor, q`); + `mirror.get_issue(conn, key) -> dict | None` (+ `links_out`, `links_in`, `note`, `commands`); + `mirror.get_note(conn, key)`, `mirror.set_note(conn, key, fields, updated_by) -> dict`, `mirror.rename_note(conn, old_key, new_key)`. + +- [ ] **Step 1: Тесты** + +`tests/test_mirror.py`: +```python +import pytest + +from jira_hub import db, mirror + + +def issue(key, **over): + base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст", + "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me", + "duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350", + "labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300", + "jira_updated": "2026-10-11T12:00:00.000+0300"} + base.update(over) + return base + + +def test_apply_state_upserts_and_replaces_links(): + conn = db.connect(":memory:") + links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}] + out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links) + assert out == {"issues": 2, "links": 1} + mirror.apply_state(conn, [issue("A-1", summary="Новая тема")], + [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + got = mirror.get_issue(conn, "A-1") + assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"] + assert got["links_out"] == [{"target_key": "A-2", "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(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [issue("A-1"), issue("A-2")], + [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) + 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_Связанные"}] + + +def test_apply_state_marks_neighbors(): + conn = db.connect(":memory:") + 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, "A-1")["neighbor"] is False + mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL + assert mirror.get_issue(conn, "B-9")["neighbor"] is False + assert mirror.known_keys(conn) == ["A-1", "B-9"] + + +def test_note_survives_refresh_and_renames(): + conn = db.connect(":memory:") + mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") + mirror.rename_note(conn, "new:7", "A-5") + mirror.apply_state(conn, [issue("A-5")], []) + got = mirror.get_issue(conn, "A-5") + assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import" + assert mirror.get_note(conn, "new:7") is None + with pytest.raises(ValueError): + mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x") + + +def test_list_issues_filters(): + conn = db.connect(":memory:") + 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-3", sprint="Спринт 2", duedate=None)], []) + mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x") + keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})] + assert keys == ["A-1", "A-3"] # без срока — последней + assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"] + assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"] + assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"] + assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None + assert len(mirror.list_issues(conn, None, limit=1)) == 1 +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_mirror.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.mirror'` + +- [ ] **Step 3: Реализация** + +`src/jira_hub/mirror.py`: +```python +"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note). +Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" +import json + +from . import db + +ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", + "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] +NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] + +_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", + "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} + + +def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None) -> dict: + """Upsert задач; связи с source_key среди присланных задач заменяются присланными. + Связи, у которых источник не прислан, не трогаются до загрузки их источника.""" + fetched_at = fetched_at or db.now() + neighbors = set(neighbor_keys) + keys = [] + with db.transaction(conn): + for it in issues: + row = {c: it.get(c) for c in ISSUE_COLUMNS} + row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False) + row["fetched_at"] = fetched_at + row["neighbor"] = 1 if it["key"] in neighbors else 0 + cols = list(row) + conn.execute( + f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " + "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols if c != "key"), + [row[c] for c in cols]) + keys.append(it["key"]) + conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) + for link in links: + conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", + (link["source_key"], link["target_key"], link["link_type"])) + return {"issues": len(keys), "links": len(links)} + + +def known_keys(conn) -> list[str]: + return [r["key"] for r in conn.execute("SELECT key FROM issue ORDER BY key")] + + +def _issue_dict(row) -> dict: + d = dict(row) + d["labels"] = json.loads(d.get("labels") or "[]") + d["neighbor"] = bool(d.get("neighbor")) + return d + + +def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: + filters = filters or {} + where, args = [], [] + for name, sql in _FILTERS.items(): + if filters.get(name) not in (None, ""): + where.append(sql) + args.append(filters[name]) + if filters.get("label"): + where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)") + args.append(filters["label"]) + if filters.get("neighbor") not in (None, ""): + where.append("i.neighbor = ?") + args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0) + if filters.get("q"): + where.append("(i.summary LIKE ? OR i.key LIKE ?)") + args += [f"%{filters['q']}%", f"%{filters['q']}%"] + sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + + (" WHERE " + " AND ".join(where) if where else "") + + " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?") + return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])] + + +def get_issue(conn, key) -> dict | None: + row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone() + if row is None: + return None + d = _issue_dict(row) + d["links_out"] = [dict(r) for r in conn.execute( + "SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))] + d["links_in"] = [dict(r) for r in conn.execute( + "SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))] + d["note"] = get_note(conn, key) + d["commands"] = [dict(r) for r in conn.execute( + "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", + (f'%"{key}"%',))] + return d + + +def get_note(conn, key) -> dict | None: + row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone() + return dict(row) if row else None + + +def set_note(conn, key, fields: dict, updated_by: str) -> dict: + unknown = set(fields) - set(NOTE_FIELDS) + if unknown: + raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}") + cur = get_note(conn, key) or {"key": key} + cur.update(fields) + cur["updated_at"], cur["updated_by"] = db.now(), updated_by + cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"] + conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " + "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]), + [cur.get(c) for c in cols]) + return get_note(conn, key) + + +def rename_note(conn, old_key, new_key) -> None: + """После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом, + если вдруг была, уступает место).""" + if get_note(conn, old_key) is None: + return + conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) + conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `uv run pytest tests/test_mirror.py -v` +Expected: 5 passed + +- [ ] **Step 5: Коммит** + +```bash +git add src/jira_hub/mirror.py tests/test_mirror.py +git commit -m "feat(hub): зеркало задач, связи, служебные заметки + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: Контракт команд + +**Files:** +- Create: `src/jira_hub/commands.py` +- Test: `tests/test_commands.py` + +**Interfaces:** +- Consumes: `mirror.get_issue`. +- Produces: `commands.SPEC`, `commands.WRITE_TYPES`, `commands.CommandError(ValueError)`, `commands.is_temp(key) -> bool`, + `commands.validate(commands: list) -> list[{"type", "params"}]`, `commands.referenced_keys(cmd) -> list[str]`, + `commands.temp_keys_used(cmd) -> list[str]`, `commands.substitute(cmd_type, params, mapping) -> dict`, + `commands.estimate(types: list[str], pacing: dict) -> int`, + `commands.check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]`. + +- [ ] **Step 1: Тесты** + +`tests/test_commands.py`: +```python +import pytest + +from jira_hub import commands, db, mirror +from jira_hub.commands import CommandError + + +def test_validate_ok_and_tail_keys(): + cmds = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "refresh", "params": {"keys": ["A-1", "new:60"]}}, + ]) + assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"] + assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"] + assert commands.temp_keys_used(cmds[1]) == ["new:60"] + assert commands.temp_keys_used(cmds[0]) == [] + assert commands.temp_keys_used(cmds[2]) == ["new:60"] + + +@pytest.mark.parametrize("bad, msg", [ + ([], "без команд"), + ([{"type": "ensure_all"}], "неизвестный тип"), + ([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"), + ([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"), + ([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"), + ([{"type": "refresh", "params": {}}], "без keys и jql"), + ([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"), + ([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"), +]) +def test_validate_errors(bad, msg): + with pytest.raises(CommandError, match=msg): + commands.validate(bad) + + +def test_substitute_and_estimate(): + p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"}) + assert p["source"] == "A-9" + p = commands.substitute("refresh", {"keys": ["new:60", "A-1"]}, {"new:60": "A-9"}) + assert p["keys"] == ["A-9", "A-1"] + p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"}, + {"new:2": "A-7", "new:60": "A-9"}) + assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7" + assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 + + +def _mirror(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", + "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", + "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov"}], + [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + return conn + + +LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"} + + +@pytest.mark.parametrize("cmd, expected", [ + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "priority": "Высокий"}}, True), + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}, False), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["kind-dev"], "labels_remove": ["fact-done"]}}, True), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["kind-dev"]}}, False), + ({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, True), + ({"type": "ensure_status", "params": {"key": "A-1", "target": "Разработка"}}, False), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, True), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "блокирует начало"}}, False), + ({"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, False), + ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True), + ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False), + ({"type": "refresh", "params": {"keys": ["A-1"]}}, False), + ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), +]) +def test_check_confirmed(cmd, expected): + ok, _ = commands.check_confirmed(_mirror(), cmd, LT) + assert ok is expected + + +def test_check_confirmed_issue_by_summary(monkeypatch): + conn = _mirror() + conn.execute("UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now') WHERE key = 'A-1'") + ok, result = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}, LT) + assert ok and result == {"key": "A-1"} + ok, _ = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Другая"}}, LT) + assert not ok +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_commands.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.commands'` + +- [ ] **Step 3: Реализация** + +`src/jira_hub/commands.py`: +```python +"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md). +Здесь валидация, работа с ключами (в том числе временными new:), оценка длительности и +проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" +import re + +from . import mirror + +TEMP_KEY = re.compile(r"^new:\d+$") +FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"] + +SPEC = { + "ensure_issue": {"required": ["temp_key", "issue_type", "summary"], + "optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels", + "epic_key", "assignee"], + "keys": ["temp_key", "epic_key"]}, + "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], + "keys": ["key", "epic_key"]}, + "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]}, + "ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]}, + "ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]}, + "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]}, + "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]}, +} +WRITE_TYPES = [t for t in SPEC if t != "refresh"] + + +class CommandError(ValueError): + """Ошибка валидации пакета — наружу уходит как 422.""" + + +def is_temp(key) -> bool: + return isinstance(key, str) and bool(TEMP_KEY.match(key)) + + +def referenced_keys(cmd) -> list[str]: + out = [] + for field in SPEC[cmd["type"]]["keys"]: + value = cmd["params"].get(field) + if isinstance(value, list): + out += [v for v in value if isinstance(v, str) and v] + elif isinstance(value, str) and value: + out.append(value) + return out + + +def temp_keys_used(cmd) -> list[str]: + """Чужие временные ключи (свой temp_key у ensure_issue — не зависимость).""" + own = cmd["params"].get("temp_key") if cmd["type"] == "ensure_issue" else None + return [k for k in referenced_keys(cmd) if is_temp(k) and k != own] + + +def validate(commands) -> list[dict]: + if not isinstance(commands, list) or not commands: + raise CommandError("пакет без команд") + defined, out = set(), [] + for i, cmd in enumerate(commands, 1): + if not isinstance(cmd, dict) or "type" not in cmd: + raise CommandError(f"команда {i}: нет type") + ctype, params = cmd["type"], cmd.get("params") or {} + if ctype not in SPEC: + raise CommandError(f"команда {i}: неизвестный тип «{ctype}»") + if not isinstance(params, dict): + raise CommandError(f"команда {i}: params не объект") + spec = SPEC[ctype] + missing = [f for f in spec["required"] if params.get(f) in (None, "", [])] + if missing: + raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}") + unknown = set(params) - set(spec["required"]) - set(spec["optional"]) + if unknown: + raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}") + if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]): + raise CommandError(f"команда {i}: ensure_fields без полей") + if ctype == "refresh" and not (params.get("keys") or params.get("jql")): + raise CommandError(f"команда {i}: refresh без keys и jql") + if ctype == "ensure_issue": + if not is_temp(params["temp_key"]): + raise CommandError(f"команда {i}: temp_key должен быть new:") + if params["temp_key"] in defined: + raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен") + if params["issue_type"] not in ("task", "epic"): + raise CommandError(f"команда {i}: issue_type — task или epic") + for key in temp_keys_used({"type": ctype, "params": params}): + if key not in defined: + raise CommandError(f"команда {i}: временный ключ {key} не объявлен ранее в пакете командой ensure_issue") + if ctype == "ensure_issue": + defined.add(params["temp_key"]) + out.append({"type": ctype, "params": params}) + return out + + +def substitute(cmd_type, params, mapping: dict) -> dict: + """Подставить настоящие ключи вместо временных (temp_key самой ensure_issue не трогается).""" + out = dict(params) + for field in SPEC[cmd_type]["keys"]: + if field == "temp_key": + continue + value = out.get(field) + if isinstance(value, list): + out[field] = [mapping.get(v, v) for v in value] + elif isinstance(value, str): + out[field] = mapping.get(value, value) + return out + + +def estimate(types, pacing: dict) -> int: + return sum(int(pacing.get(t, 0)) for t in types) + + +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_comment", "refresh"): + return False, None + if ctype == "ensure_issue": + row = conn.execute("SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days') " + "ORDER BY created DESC LIMIT 1", (p["summary"],)).fetchone() + return (True, {"key": row["key"]}) if row else (False, None) + issue = mirror.get_issue(conn, p.get("key") or p.get("source")) + if issue is None: + return False, None + if ctype == "ensure_fields": + for field in FIELD_PARAMS: + if field in p and (issue.get(field) or None) != (p[field] or None): + return False, None + if any(l not in issue["labels"] for l in p.get("labels_add", [])): + return False, None + if any(l in issue["labels"] for l in p.get("labels_remove", [])): + return False, None + return True, None + if ctype == "ensure_status": + target = p["target"] + final = target[-1] if isinstance(target, list) else target + return issue["status"] == final, None + if ctype == "ensure_link": + jira_type = link_types.get(p["link_type"], p["link_type"]) + return any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"]), None + if ctype == "ensure_sprint": + want = None if p["sprint"] == "backlog" else p["sprint"] + return (issue.get("sprint") or None) == want, None + return False, None +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `uv run pytest tests/test_commands.py -v` +Expected: все passed (1 + 10 параметризованных + 1 + 14 параметризованных + 1) + +- [ ] **Step 5: Коммит** + +```bash +git add src/jira_hub/commands.py tests/test_commands.py +git commit -m "feat(hub): контракт команд — валидация, временные ключи, оценка, подтверждение по состоянию + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: Очередь — пакеты, выдача, отчёты, отмена, сверка, автообновление + +**Files:** +- Create: `src/jira_hub/queue.py`, `src/jira_hub/backup.py` +- Modify: `src/jira_hub/db.py` (добавить `parse`, `utcnow`) +- Test: `tests/test_queue.py` + +**Interfaces:** +- Consumes: `commands.validate/referenced_keys/temp_keys_used/substitute/estimate/check_confirmed/is_temp`, `settings.pacing/get`, `mirror.rename_note/apply_state`, `db.transaction/now`. +- Produces: `db.parse(ts: str) -> datetime`, `db.utcnow() -> datetime`; + `backup.make_backup(conn, backups_dir: Path, label: str, keep: int) -> Path`; + `queue.TERMINAL`, `queue.STALE_MINUTES = 10`, `queue.ClaimedError(Exception)` (атрибут `claimed_at`), + `queue.create_batch(conn, title, author, cmds) -> {"id", "estimate_seconds", "count"}`, + `queue.claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None` + (`{"id","type","params","pause_seconds","batch_id"}`; `backup` — callable(label)), + `queue.report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict`, + `queue.record_state(conn, command_id, issues_count, links_count)`, + `queue.reconcile(conn) -> list[int]`, `queue.cancel_batch(conn, batch_id) -> dict`, `queue.cancel_command(conn, command_id, force=False) -> dict`, + `queue.retry_command(conn, command_id, author) -> dict`, `queue.batch_view(conn, batch_id) -> dict | None` + (`status`, `counts`, `remaining_seconds`, `commands`), `queue.list_batches(conn, limit=20) -> list[dict]`, + `queue.agent_view(conn) -> dict` (`online`, `warning`, `rate_limited_24h`, `stuck`), `queue.maybe_auto_refresh(conn) -> dict | None`, + `queue.get_command(conn, command_id) -> dict | None`. + +- [ ] **Step 1: Тесты** + +`tests/test_queue.py`: +```python +import pytest + +from jira_hub import db, mirror, queue, settings +from jira_hub.commands import CommandError +from jira_hub.queue import ClaimedError + +LT = {"связанные": "ASUPR_Связанные"} + + +def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", LT) + return conn + + +def fields(key, **extra): + return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}} + + +def test_create_batch_adds_tail_refresh_and_estimate(): + conn = fresh() + 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"]) + assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"] + assert view["commands"][2]["params"] == {"keys": ["A-1", "A-2"]} + assert b["estimate_seconds"] == 120 + 30 + 2 and b["count"] == 3 + assert view["status"] == "queued" and view["author"] == "claude" + + +def test_refresh_only_batch_has_no_tail(): + conn = fresh() + b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) + assert b["count"] == 1 + with pytest.raises(CommandError): + queue.create_batch(conn, "x", "claude", [{"type": "nope"}]) + + +def test_claim_order_pause_and_report(): + conn = fresh() + b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")]) + b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")]) + c = queue.claim(conn, agent_version=1, settings_version=1) + assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"] + assert queue.claim(conn) is None # одна команда в полёте + assert queue.agent_view(conn)["current_command_id"] == c["id"] + done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2) + assert done["status"] == "done" and done["jira_calls"] == 2 + again = queue.report_result(conn, c["id"], "failed", error="поздно") + assert again["status"] == "done" # повторный отчёт не меняет + tail = queue.claim(conn) + assert tail["type"] == "refresh" and tail["pause_seconds"] == 2 + assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1" + queue.record_state(conn, tail["id"], 1, 0) + queue.report_result(conn, tail["id"], "done", jira_calls=1) + run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone() + assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1 + assert queue.batch_view(conn, b1["id"])["status"] == "done" + assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running" + + +def test_claim_is_exclusive(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + first, second = queue.claim(conn), queue.claim(conn) + assert first is not None and second is None + + +def test_stale_claimed_does_not_block(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + nxt = queue.claim(conn) + assert nxt["params"]["key"] == "A-2" + assert queue.agent_view(conn)["stuck"][0]["id"] == c["id"] + + +def test_temp_key_substitution_and_skip(): + conn = fresh() + mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") + queue.create_batch(conn, "создать", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая"}}, + {"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}}, + ]) + c1 = queue.claim(conn) + queue.report_result(conn, c1["id"], "done", result={"key": "A-90"}) + assert mirror.get_note(conn, "A-90")["fact_status"] == "В работе" and mirror.get_note(conn, "new:60") is None + c2 = queue.claim(conn) + assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90" + queue.report_result(conn, c2["id"], "done") + c3 = queue.claim(conn) + queue.report_result(conn, c3["id"], "failed", error="HTTP 400") + tail = queue.claim(conn) # ensure_sprint пропущена, refresh без new:61 + assert tail["type"] == "refresh" and tail["params"]["keys"] == ["A-90", "A-1"] + view = queue.batch_view(conn, c1["batch_id"]) + assert [c["status"] for c in view["commands"]][:4] == ["done", "done", "failed", "skipped"] + assert view["commands"][3]["error"] == "задача new:61 не создана" + + +def test_cancel_and_retry(): + conn = fresh() + b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + with pytest.raises(ClaimedError): + queue.cancel_command(conn, c["id"]) + view = queue.cancel_batch(conn, b["id"]) + assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"] + assert queue.claim(conn) is None + forced = queue.cancel_command(conn, c["id"], force=True) + assert forced["status"] == "cancelled" + after = queue.report_result(conn, c["id"], "done", result={"changed": []}) + assert after["status"] == "cancelled" and after["result"] is None # отчёт после force-отмены не затирает + assert queue.batch_view(conn, b["id"])["status"] == "cancelled" + r = queue.retry_command(conn, c["id"], "claude") + assert queue.batch_view(conn, r["id"])["title"] == f"Повтор команды {c['id']}" + assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"] + + +def test_report_after_force_cancel_keeps_cancelled(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" + + +def test_reconcile_confirms_by_state(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + queue.report_result(conn, r["id"], "done") + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" + assert r["batch_id"] == c["batch_id"] # выдался хвостовой refresh первого пакета + assert queue.batch_view(conn, c["batch_id"])["status"] == "done" + assert queue.batch_view(conn, b["id"])["status"] == "queued" + + +def test_auto_refresh_conditions(): + conn = fresh() + assert queue.maybe_auto_refresh(conn) is None # агент не на связи + queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста + b = queue.maybe_auto_refresh(conn) + assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление" + assert queue.maybe_auto_refresh(conn) is None # очередь занята + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done") + assert queue.maybe_auto_refresh(conn) is None # интервал не прошёл + conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") + assert queue.maybe_auto_refresh(conn) is not None + settings.set_(conn, "auto_refresh_minutes", 0) + conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") + assert queue.maybe_auto_refresh(conn) is None + + +def test_backup_called_for_first_write_only(tmp_path): + conn = fresh() + calls = [] + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c1 = queue.claim(conn, backup=calls.append) + queue.report_result(conn, c1["id"], "done") + queue.claim(conn, backup=calls.append) + assert calls == [f"batch-{c1['batch_id']}"] + + +def test_no_backup_for_refresh_batch(): + conn = fresh() + calls = [] + queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) + queue.claim(conn, backup=calls.append) + assert calls == [] + + +def test_make_backup_rotates(tmp_path): + from jira_hub import backup + conn = db.connect(tmp_path / "hub.sqlite") + for i in range(4): + backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) + names = sorted(p.name for p in (tmp_path / "backups").glob("*.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 +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_queue.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.queue'` + +- [ ] **Step 3: Добавить в db.py** + +В конец `src/jira_hub/db.py`: +```python +def utcnow() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def parse(ts: str) -> datetime: + """ISO-8601 из базы → aware datetime (naive считаем UTC).""" + d = datetime.fromisoformat(ts) + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) +``` + +- [ ] **Step 4: backup.py** + +`src/jira_hub/backup.py`: +```python +"""Резервная копия файла базы перед пакетом с записями в Jira: VACUUM INTO даёт согласованный снимок при WAL.""" +from datetime import datetime +from pathlib import Path + + +def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path: + backups_dir = Path(backups_dir) + backups_dir.mkdir(parents=True, exist_ok=True) + target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite" + conn.execute("VACUUM INTO ?", (str(target),)) + files = sorted(backups_dir.glob("*.sqlite")) + if keep > 0: + for old in files[:-keep]: + old.unlink() + return target +``` + +- [ ] **Step 5: queue.py** + +`src/jira_hub/queue.py`: +```python +"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление. +Хаб никогда не повторяет команды сам (design.md, решение 4).""" +import json +from datetime import timedelta + +from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) +from . import commands as C +from . import db, mirror, settings + +TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} +STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа» +ONLINE_MINUTES = 5 + + +class ClaimedError(Exception): + def __init__(self, claimed_at): + super().__init__(f"команда взята агентом {claimed_at}; отмена — с force") + self.claimed_at = claimed_at + + +def _cmd(row) -> dict: + d = dict(row) + d["params"] = json.loads(d["params"]) + d["result"] = json.loads(d["result"]) if d.get("result") else None + return d + + +def get_command(conn, command_id) -> dict | None: + row = conn.execute("SELECT * FROM command WHERE id = ?", (command_id,)).fetchone() + return _cmd(row) if row else None + + +def _dump(value) -> str: + return json.dumps(value, ensure_ascii=False) + + +# --- пакеты ----------------------------------------------------------------- + +def create_batch(conn, title, author, cmds) -> dict: + cmds = C.validate(cmds) + keys = [] + for c in cmds: + keys += [k for k in C.referenced_keys(c) if k not in keys] + if any(c["type"] != "refresh" for c in cmds) and keys: + cmds.append({"type": "refresh", "params": {"keys": keys}}) + est = C.estimate([c["type"] for c in cmds], settings.pacing(conn)) + with db.transaction(conn): + cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds) VALUES (?, ?, ?, ?)", + (title, author or "api", db.now(), est)) + batch_id = cur.lastrowid + defined = {} + for seq, c in enumerate(cmds, 1): + deps = [defined[k] for k in C.temp_keys_used(c) if k in defined] + cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, depends_on) VALUES (?, ?, ?, ?, ?)", + (batch_id, seq, c["type"], _dump(c["params"]), deps[0] if deps else None)) + if c["type"] == "ensure_issue": + defined[c["params"]["temp_key"]] = cur.lastrowid + return {"id": batch_id, "estimate_seconds": est, "count": len(cmds)} + + +def _status_of(cmds, cancelled_at=None) -> str: + sts = [c["status"] for c in cmds] + if not sts: + return "done" + if all(s in TERMINAL for s in sts): + return "cancelled" if cancelled_at and all(s == "cancelled" for s in sts) else "done" + if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts): + return "running" + return "queued" + + +def batch_view(conn, batch_id, with_commands=True) -> dict | None: + b = conn.execute("SELECT * FROM batch WHERE id = ?", (batch_id,)).fetchone() + if b is None: + return None + cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))] + pacing = settings.pacing(conn) + d = dict(b) + d["status"] = _status_of(cmds, b["cancelled_at"]) + d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})} + d["remaining_seconds"] = C.estimate([c["type"] for c in cmds if c["status"] in ("queued", "claimed")], pacing) + if with_commands: + d["commands"] = cmds + return d + + +def list_batches(conn, limit=20) -> list[dict]: + ids = [r["id"] for r in conn.execute("SELECT id FROM batch ORDER BY id DESC LIMIT ?", (int(limit),))] + return [batch_view(conn, i, with_commands=False) for i in ids] + + +# --- выдача ----------------------------------------------------------------- + +def _mapping(conn, batch_id) -> dict: + """temp_key → настоящий ключ по завершённым ensure_issue пакета.""" + rows = conn.execute("SELECT json_extract(params, '$.temp_key') AS t, json_extract(result, '$.key') AS k FROM command " + "WHERE batch_id = ? AND type = 'ensure_issue' AND status IN ('done', 'confirmed_by_state')", + (batch_id,)).fetchall() + return {r["t"]: r["k"] for r in rows if r["k"]} + + +def _definer_status(conn, batch_id, temp_key): + r = conn.execute("SELECT status FROM command WHERE batch_id = ? AND type = 'ensure_issue' " + "AND json_extract(params, '$.temp_key') = ?", (batch_id, temp_key)).fetchone() + return r["status"] if r else "failed" + + +def _next(conn) -> dict | None: + 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() + for row in rows: + cmd = _cmd(row) + needed = C.temp_keys_used(cmd) + if needed: + mapping = _mapping(conn, cmd["batch_id"]) + unresolved = [k for k in needed if k not in mapping] + pending = [k for k in unresolved if _definer_status(conn, cmd["batch_id"], k) not in TERMINAL] + if pending: + return None # создание ещё не завершено — ждём + cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping) + if cmd["type"] == "refresh": + cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)] + unresolved = [] if cmd["params"]["keys"] else unresolved + if unresolved: + conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", + (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) + continue + return cmd + return None + + +def _fresh_claimed_exists(conn) -> bool: + cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat() + return conn.execute("SELECT 1 FROM command WHERE status = 'claimed' AND claimed_at > ? LIMIT 1", (cutoff,)).fetchone() is not None + + +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None: + now = db.now() + to_backup = None + with db.transaction(conn): + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), " + "settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1", + (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) + if _fresh_claimed_exists(conn): + return None + cmd = _next(conn) + if cmd is None: + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") + return None + pause = int(settings.pacing(conn).get(cmd["type"], 0)) + first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", + (cmd["batch_id"],)).fetchone()["n"] == 0 + has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1", + (cmd["batch_id"],)).fetchone() is not None + conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ? WHERE id = ?", + (now, pause, _dump(cmd["params"]), cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + if cmd["type"] == "refresh": + p = cmd["params"] + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)", + (now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"])) + if first and has_writes: + to_backup = f"batch-{cmd['batch_id']}" + if to_backup and backup: + backup(to_backup) + return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]} + + +# --- отчёты ----------------------------------------------------------------- + +def record_state(conn, command_id, issues_count, links_count) -> None: + conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?", + (int(issues_count), int(links_count), command_id)) + + +def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict: + if status not in ("done", "failed"): + raise ValueError("status — done или failed") + with db.transaction(conn): + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + if cmd["status"] in TERMINAL: + return cmd # идемпотентно; cancelled не затирается + now = db.now() + conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id)) + conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?", + (now, command_id)) + if cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key"): + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + if cmd["type"] == "refresh": + conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", + (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) + if cmd["type"] == "refresh" and status == "done": + reconcile(conn) + return get_command(conn, command_id) + + +def reconcile(conn) -> list[int]: + """Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state.""" + link_types = settings.get(conn, "link_types") or {} + changed = [] + for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall(): + cmd = _cmd(row) + ok, result = C.check_confirmed(conn, cmd, link_types) + if not ok: + continue + conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? WHERE id = ?", + (db.now(), _dump(result) if result else None, cmd["id"])) + if cmd["type"] == "ensure_issue" and result: + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + changed.append(cmd["id"]) + return changed + + +# --- отмена и повтор ---------------------------------------------------------- + +def cancel_batch(conn, batch_id) -> dict: + with db.transaction(conn): + if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None: + raise KeyError(batch_id) + now = db.now() + conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id)) + conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id)) + return batch_view(conn, batch_id) + + +def cancel_command(conn, command_id, force=False) -> dict: + with db.transaction(conn): + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + if cmd["status"] == "claimed" and not force: + raise ClaimedError(cmd["claimed_at"]) + if cmd["status"] in ("queued", "claimed"): + conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE id = ?", (db.now(), command_id)) + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (command_id,)) + return get_command(conn, command_id) + + +def retry_command(conn, command_id, author) -> dict: + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}]) + + +# --- агент и автообновление ---------------------------------------------------- + +def agent_view(conn) -> dict: + d = dict(conn.execute("SELECT * FROM agent_status WHERE id = 1").fetchone()) + now = db.utcnow() + d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES) + min_version = int(settings.get(conn, "min_agent_version") or 0) + d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None + d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?", + ((now - timedelta(days=1)).isoformat(),)).fetchone()["n"] + cutoff = (now - timedelta(minutes=STALE_MINUTES)).isoformat() + d["stuck"] = [dict(r) for r in conn.execute( + "SELECT id, type, claimed_at FROM command WHERE status = 'claimed' AND claimed_at <= ? ORDER BY id", (cutoff,))] + return d + + +def maybe_auto_refresh(conn) -> dict | None: + minutes = int(settings.get(conn, "auto_refresh_minutes") or 0) + if minutes <= 0 or not agent_view(conn)["online"]: + return None + if conn.execute("SELECT 1 FROM command WHERE status = 'queued' LIMIT 1").fetchone() or _fresh_claimed_exists(conn): + return None + last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"] + if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes): + return None + return create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": settings.get(conn, "base_jql")}}]) +``` + +- [ ] **Step 6: Запустить — зелёный** + +Run: `uv run pytest tests/test_queue.py -v` +Expected: 13 passed. Если `test_temp_key_substitution_and_skip` падает на порядке ключей хвостового `refresh` — проверить, что `referenced_keys` обходит поля в порядке `SPEC[...]["keys"]`, а `create_batch` сохраняет порядок первого появления. + +- [ ] **Step 7: Весь набор и коммит** + +Run: `uv run pytest -q` +Expected: все зелёные + +```bash +git add src/jira_hub/queue.py src/jira_hub/backup.py src/jira_hub/db.py tests/test_queue.py +git commit -m "feat(hub): очередь команд — пакеты, выдача, отчёты, отмена, сверка, автообновление, резервные копии + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: HTTP API и клиент хаба + +**Files:** +- Create: `src/jira_hub/api.py`, `src/jira_hub/client.py` +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes: всё из Tasks 2–5. +- Produces: `api.Hub(db_path, backups_dir=None)` (`.conn()`, `.make_backup(label)`, `.claim_wait` — секунды долгого запроса, по умолчанию 30), + `api.make_server(hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer`, `api.serve(hub, host, port)`, `api.ApiError`; + `client.HubClient(base_url, token, timeout=60)` с `.get(path, **params)`, `.post(path, body)`, `.put(path, body)`, + `HubClient.from_home()` (порт и клиентский токен из базы в `paths.db_path()`), `client.HubError(status, error, detail)`, + `client.HubUnavailable(Exception)`. +- Контракт тела `POST /agent/state`: `{command_id, issues, links, neighbor_keys, page, last_page}`. + +- [ ] **Step 1: Тесты** + +`tests/test_api.py`: +```python +import threading + +import pytest + +from jira_hub import api, db, settings +from jira_hub.client import HubClient, HubError + + +@pytest.fixture +def hub(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_port}" + yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base} + srv.shutdown() + + +def test_auth_scopes(hub): + with pytest.raises(HubError) as e: + hub["agent"].get("/issues") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["client"].post("/agent/claim", {}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + HubClient(hub["base"], "wrong").get("/issues") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["client"].get("/nope") + assert e.value.status == 404 + + +def test_agent_flow(hub): + c = hub["client"] + b = c.post("/batches", {"title": "срок", "author": "claude", + "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + assert b["count"] == 2 + a = hub["agent"] + claim = a.post("/agent/claim", {"agent_version": 1, "settings_version": 1}) + cmd = claim["command"] + assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1 + assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"] + assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные" + assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен + r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2}) + assert r["status"] == "done" + assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done" + tail = a.post("/agent/claim", {})["command"] + assert tail["type"] == "refresh" + out = a.post("/agent/state", {"command_id": tail["id"], "page": 1, "last_page": True, + "issues": [{"key": "A-1", "summary": "Т", "duedate": "2026-10-23", "labels": ["AWPCA"], + "created": "2026-10-10T10:00:00.000+0300"}, + {"key": "B-1", "summary": "Сосед", "labels": []}], + "links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}], + "neighbor_keys": ["B-1"]}) + assert out == {"issues": 2, "links": 1} + assert a.get("/agent/known-keys")["keys"] == ["A-1", "B-1"] + a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1}) + assert c.get("/batches/%d" % b["id"])["status"] == "done" + issue = c.get("/issues/A-1") + assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}] + assert c.get("/issues/B-1")["neighbor"] is True + runs = c.get("/sync-runs", limit=5)["runs"] + assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"] + assert c.get("/agent")["online"] is True + + +def test_interface_routes(hub): + c = hub["client"] + with pytest.raises(HubError) as e: + c.get("/issues/A-404") + assert e.value.status == 404 + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []}, + {"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []}) + assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"] + note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"}) + assert note["fact_status"] == "В работе" and note["updated_by"] == "owner" + with pytest.raises(HubError) as e: + c.put("/issues/A-1/note", {"summary": "x"}) + assert e.value.status == 422 + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1"}}]}) + assert e.value.status == 422 and "без полей" in e.value.detail + b = c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}, + {"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]}) + assert c.get("/batches", limit=5)["batches"][0]["id"] == b["id"] + cmd = hub["agent"].post("/agent/claim", {})["command"] + with pytest.raises(HubError) as e: + c.post(f"/commands/{cmd['id']}/cancel", {}) + assert e.value.status == 409 and e.value.detail + assert c.post(f"/commands/{cmd['id']}/cancel", {"force": True})["status"] == "cancelled" + assert c.post(f"/batches/{b['id']}/cancel", {})["status"] == "cancelled" + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}" + full = c.post("/refresh", {"full": True}) + assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels = AWPCA" + keys = c.post("/refresh", {"keys": ["A-1"]}) + assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]} + + +def test_settings_put_rejects_protected(hub): + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + for key in ("tokens", "settings_version"): + with pytest.raises(HubError) as e: + c.put("/settings", {key: "x", "pacing": {"refresh": 1}}) + assert e.value.status == 422 + assert c.get("/settings")["settings_version"] == v0 + assert "tokens" not in c.get("/settings") + v1 = c.put("/settings", {"pacing": {"refresh": 1}})["settings_version"] + assert v1 == v0 + 1 and c.get("/settings")["pacing"]["refresh"] == 1 + + +def test_backup_created_on_first_write(hub, tmp_path): + c = hub["client"] + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + hub["agent"].post("/agent/claim", {}) + assert len(list((tmp_path / "backups").glob("*-batch-*.sqlite"))) == 1 + + +def test_client_unavailable(): + from jira_hub.client import HubUnavailable + with pytest.raises(HubUnavailable): + HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues") +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_api.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.api'` + +- [ ] **Step 3: client.py** + +`src/jira_hub/client.py`: +```python +"""HTTP-клиент хаба: для MCP, тестов и скриптов. Ошибки API → HubError, недоступный хаб → HubUnavailable.""" +import json +import urllib.error +import urllib.parse +import urllib.request + +from . import db, paths, settings + + +class HubError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(f"HTTP {status} {error}: {detail or ''}".strip()) + self.status, self.error, self.detail = status, error, detail + + +class HubUnavailable(Exception): + """Хаб не отвечает — обычно не запущен (jira-hub serve).""" + + +class HubClient: + def __init__(self, base_url: str, token: str, timeout: float = 60): + self.base, self.token, self.timeout = base_url.rstrip("/"), token, timeout + + @classmethod + def from_home(cls, timeout: float = 60) -> "HubClient": + conn = db.connect(paths.db_path()) + try: + settings.ensure_defaults(conn) + return cls(f"http://127.0.0.1:{settings.get(conn, 'port')}", settings.tokens(conn)["client"], timeout) + finally: + conn.close() + + def call(self, method: str, path: str, body=None, params=None): + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Bearer " + self.token) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as e: + try: + payload = json.loads(e.read().decode("utf-8")) + except Exception: + payload = {"error": "http", "detail": str(e)} + raise HubError(e.code, payload.get("error", "http"), payload.get("detail")) from None + except (urllib.error.URLError, ConnectionError, TimeoutError) as e: + raise HubUnavailable(f"хаб не отвечает ({self.base}): {e}") from None + + def get(self, path, **params): + return self.call("GET", path, params=params or None) + + def post(self, path, body=None): + return self.call("POST", path, body if body is not None else {}) + + def put(self, path, body): + return self.call("PUT", path, body) +``` + +- [ ] **Step 4: api.py** + +`src/jira_hub/api.py`: +```python +"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). +Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" +import json +import re +import threading +import time +import traceback +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from . import backup, db, mirror, queue, settings +from .commands import CommandError +from .paths import backups_dir as default_backups_dir +from .queue import ClaimedError + + +class ApiError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(error) + self.status, self.error, self.detail = status, error, detail + + +class Hub: + def __init__(self, db_path, backups_dir=None): + self.db_path = Path(db_path) + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.claim_wait = 30 + conn = self.conn() + settings.ensure_defaults(conn) + conn.close() + + def conn(self): + return db.connect(self.db_path) + + def make_backup(self, label): + conn = self.conn() + try: + backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30)) + finally: + conn.close() + + +# --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- + +def _int(m, i=1): + return int(m.group(i)) + + +def _not_found(obj, what): + if obj is None: + raise ApiError(404, "not_found", what) + return obj + + +def h_claim(hub, conn, m, q, body): + deadline = time.monotonic() + hub.claim_wait + while True: + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup) + if cmd or time.monotonic() >= deadline: + break + time.sleep(1) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")} + + +def h_agent_settings(hub, conn, m, q, body): + return 200, settings.for_agent(conn) + + +def h_agent_command(hub, conn, m, q, body): + return 200, _not_found(queue.get_command(conn, _int(m)), "command") + + +def h_agent_result(hub, conn, m, q, body): + try: + cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), + body.get("jira_calls", 0), body.get("rate_limited", 0)) + except KeyError: + raise ApiError(404, "not_found", "command") + except ValueError as e: + raise ApiError(422, "bad_status", str(e)) + return 200, cmd + + +def h_agent_state(hub, conn, m, q, body): + out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or []) + if body.get("command_id"): + queue.record_state(conn, body["command_id"], out["issues"], out["links"]) + return 200, out + + +def h_known_keys(hub, conn, m, q, body): + return 200, {"keys": mirror.known_keys(conn)} + + +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q") + + +def h_issues(hub, conn, m, q, body): + filters = {k: q.get(k) for k in ISSUE_FILTERS} + return 200, {"issues": mirror.list_issues(conn, filters, int(q.get("limit", 50)), int(q.get("offset", 0)))} + + +def h_issue(hub, conn, m, q, body): + return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") + + +def h_note_put(hub, conn, m, q, body): + fields = {k: v for k, v in body.items() if k != "updated_by"} + try: + return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api") + except ValueError as e: + raise ApiError(422, "bad_note", str(e)) + + +def h_batches_post(hub, conn, m, q, body): + if not body.get("title"): + raise ApiError(422, "bad_batch", "нет title") + try: + return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + + +def h_batches_get(hub, conn, m, q, body): + return 200, {"batches": queue.list_batches(conn, int(q.get("limit", 20)))} + + +def h_batch_get(hub, conn, m, q, body): + return 200, _not_found(queue.batch_view(conn, _int(m)), "batch") + + +def h_batch_cancel(hub, conn, m, q, body): + try: + return 200, queue.cancel_batch(conn, _int(m)) + except KeyError: + raise ApiError(404, "not_found", "batch") + + +def h_command_retry(hub, conn, m, q, body): + try: + return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") + except KeyError: + raise ApiError(404, "not_found", "command") + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + + +def h_command_cancel(hub, conn, m, q, body): + try: + return 200, queue.cancel_command(conn, _int(m), bool(body.get("force"))) + except KeyError: + raise ApiError(404, "not_found", "command") + except ClaimedError as e: + raise ApiError(409, "claimed", e.claimed_at) + + +def h_refresh(hub, conn, m, q, body): + if body.get("full"): + params = {"jql": settings.get(conn, "base_jql")} + elif body.get("jql"): + params = {"jql": body["jql"]} + elif body.get("keys"): + params = {"keys": list(body["keys"])} + else: + raise ApiError(422, "bad_refresh", "нужно full, jql или keys") + return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) + + +def h_agent_get(hub, conn, m, q, body): + return 200, queue.agent_view(conn) + + +def h_sync_runs(hub, conn, m, q, body): + rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int(q.get("limit", 20)),)).fetchall() + return 200, {"runs": [dict(r) for r in rows]} + + +def h_settings_get(hub, conn, m, q, body): + return 200, settings.get_all(conn) + + +def h_settings_put(hub, conn, m, q, body): + bad = [k for k in body if k in settings.PROTECTED] + if bad: + raise ApiError(422, "protected", ", ".join(bad)) + version = settings.get(conn, "settings_version") + for key, value in body.items(): + version = settings.set_(conn, key, value) + return 200, {"settings_version": version} + + +ROUTES = [ + ("POST", r"^/agent/claim$", h_claim, "agent"), + ("GET", r"^/agent/settings$", h_agent_settings, "agent"), + ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), + ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), + ("POST", r"^/agent/state$", h_agent_state, "agent"), + ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), + ("GET", r"^/issues$", h_issues, "client"), + ("GET", r"^/issues/([^/]+)$", h_issue, "client"), + ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), + ("POST", r"^/batches$", h_batches_post, "client"), + ("GET", r"^/batches$", h_batches_get, "client"), + ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), + ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), + ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), + ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), + ("POST", r"^/refresh$", h_refresh, "client"), + ("GET", r"^/agent$", h_agent_get, "client"), + ("GET", r"^/sync-runs$", h_sync_runs, "client"), + ("GET", r"^/settings$", h_settings_get, "client"), + ("PUT", r"^/settings$", h_settings_put, "client"), +] +_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] + + +class Handler(BaseHTTPRequestHandler): + hub: Hub = None # подставляется в make_server + + def do_GET(self): + self._dispatch("GET") + + def do_POST(self): + self._dispatch("POST") + + def do_PUT(self): + self._dispatch("PUT") + + def log_message(self, fmt, *args): # тише стандартного: без адреса клиента + print(f"[{self.log_date_time_string()}] {fmt % args}") + + def _auth(self, scope): + token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip() + conn = self.hub.conn() + try: + tokens = settings.tokens(conn) + finally: + conn.close() + if not token or token != tokens["agent" if scope == "agent" else "client"]: + raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") + + def _dispatch(self, method): + url = urlparse(self.path) + query = {k: v[0] for k, v in parse_qs(url.query).items()} + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw.decode("utf-8")) if raw else {} + if not isinstance(body, dict): + raise ApiError(400, "bad_json", "ожидается объект") + for route_method, rx, fn, scope in _COMPILED: + if route_method != method: + continue + match = rx.match(url.path) + if not match: + continue + self._auth(scope) + conn = self.hub.conn() + try: + status, payload = fn(self.hub, conn, match, query, body) + finally: + conn.close() + return self._send(status, payload) + raise ApiError(404, "not_found", url.path) + except ApiError as e: + self._send(e.status, {"error": e.error, "detail": e.detail}) + except json.JSONDecodeError as e: + self._send(400, {"error": "bad_json", "detail": str(e)}) + except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе + traceback.print_exc() + self._send(500, {"error": "internal", "detail": str(e)}) + + def _send(self, status, payload): + data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer: + handler = type("HubHandler", (Handler,), {"hub": hub}) + return ThreadingHTTPServer((host, port), handler) + + +def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: + srv = make_server(hub, host, port) + stop = threading.Event() + + def ticker(): + while not stop.wait(60): + conn = hub.conn() + try: + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + traceback.print_exc() + finally: + conn.close() + + threading.Thread(target=ticker, daemon=True).start() + print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}") + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + stop.set() + srv.server_close() +``` + +- [ ] **Step 5: Запустить — зелёный** + +Run: `uv run pytest tests/test_api.py -v` +Expected: 6 passed + +- [ ] **Step 6: Коммит** + +```bash +git add src/jira_hub/api.py src/jira_hub/client.py tests/test_api.py +git commit -m "feat(hub): HTTP API — маршруты агента и интерфейсов, токены, долгий claim, клиент + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 7: Импорт CSV-пакета + +**Files:** +- Create: `src/jira_hub/importer.py` +- Test: `tests/test_importer.py` + +**Interfaces:** +- Consumes: `mirror.set_note/get_note/known_keys/get_issue`, `settings.get`. +- Produces: `importer.import_package(conn, folder) -> {"notes": int, "expected_links": int, "missing_keys": list[str], "missing_links": list[dict]}`. + +- [ ] **Step 1: Тест** + +`tests/test_importer.py`: +```python +import csv + +from jira_hub import db, importer, mirror, settings + + +def write_csv(path, header, rows): + with open(path, "w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + w.writerow(header) + w.writerows(rows) + + +def make_package(folder): + write_csv(folder / "tasks.csv", + ["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации", + "Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"], + [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", "Актуализация 10.10.2026. Порядок", + "", "Прогнать на стенде", "Спринт 2"], + ["AURORA-1416", "Карточка", "UI", "В работе", "", "Сделан список", "Модалка", ""]]) + write_csv(folder / "new_tasks.csv", + ["Ключ", "Тема", "Вид работы", "Статус по факту", "Описание", "Основание (внутр.)", "Спринт"], + [["AURORA-1586", "Справочники: тестирование", "тестирование", "Ожидание разработки", "h3. Контекст", "решение 11.10", "Спринт 2"], + ["", "Ещё не создана", "разработка", "Ожидание разработки", "h3. Контекст", "идея", ""]]) + write_csv(folder / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"], + [["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"], ["NEW-1", "AURORA-1529", "Роли", "новый"]]) + write_csv(folder / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], + [["AURORA-1533", "блокирует начало", "AURORA-1416", "x"], + ["new:2", "связанные", "AURORA-1533", "y"], + ["new:3", "связанные", "AURORA-1533", "z"]]) + + +def test_import_notes_and_report(tmp_path): + make_package(tmp_path) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) + 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_Связанные"}]) + rep = importer.import_package(conn, tmp_path) + assert rep["notes"] == 6 + n = mirror.get_note(conn, "AURORA-1533") + assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование" + assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import" + assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список" + assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10" + assert mirror.get_note(conn, "new:3")["owner_note"] == "Ещё не создана" + assert mirror.get_note(conn, "AURORA-1529")["work_kind"] == "эпик" + assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"] + assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется + assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] + assert importer.import_package(conn, tmp_path)["notes"] == 6 + assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6 +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_importer.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.importer'` + +- [ ] **Step 3: Реализация** + +`src/jira_hub/importer.py`: +```python +"""Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор +(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует.""" +import csv +from pathlib import Path + +from . import mirror, settings + + +def _rows(path: Path) -> list[dict]: + if not path.exists(): + return [] + with open(path, encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def _clean(value) -> str | None: + value = (value or "").strip() + return value or None + + +def import_package(conn, folder) -> dict: + folder = Path(folder) + notes = 0 + csv_keys = [] + + for r in _rows(folder / "tasks.csv"): + key = _clean(r.get("Код")) + if not key: + continue + mirror.set_note(conn, key, { + "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")), + "owner_note": _clean(r.get("Комментарий актуализации"))}, "import") + notes += 1 + csv_keys.append(key) + + new_keys = {} # new:<строка> → ключ Jira (как в jira_sync.py) + for i, r in enumerate(_rows(folder / "new_tasks.csv"), 2): + real = _clean(r.get("Ключ")) + key = real or f"new:{i}" + if real: + new_keys[f"new:{i}"] = real + csv_keys.append(real) + mirror.set_note(conn, key, { + "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "rest_text": _clean(r.get("Основание (внутр.)")), + "owner_note": None if real else _clean(r.get("Тема"))}, "import") + notes += 1 + + for r in _rows(folder / "epics.csv"): + key = _clean(r.get("Ключ")) + if not key: + continue + mirror.set_note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}, "import") + notes += 1 + csv_keys.append(key) + + link_types = settings.get(conn, "link_types") or {} + expected, missing_links = [], [] + for r in _rows(folder / "links.csv"): + src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда"))) + dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда"))) + kind = (_clean(r.get("Тип")) or "").lower() + if not src or not dst or src.startswith("new:") or dst.startswith("new:"): + continue + link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)} + expected.append(link) + issue = mirror.get_issue(conn, src) + present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]) + if not present: + missing_links.append(link) + + known = set(mirror.known_keys(conn)) + missing_keys = sorted({k for k in csv_keys if k not in known}) + return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links} +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `uv run pytest tests/test_importer.py -v` +Expected: 1 passed + +- [ ] **Step 5: Коммит** + +```bash +git add src/jira_hub/importer.py tests/test_importer.py +git commit -m "feat(hub): импорт CSV-пакета admin/jira-sync в заметки, сверка связей + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 8: MCP-сервер + +**Files:** +- Create: `src/jira_hub/mcp_server.py` +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `client.HubClient/HubError/HubUnavailable`. +- Produces: `mcp_server.mcp` (FastMCP «jira-hub»), функции-инструменты `issues, issue, notes_set, batch_create, batch_status, + batch_wait, batches, command_retry, batch_cancel, refresh, agent_status, sync_log, settings_get, settings_set` (каждая возвращает `str`), + `mcp_server.run()`, `mcp_server.POLL_SECONDS`, `mcp_server.OFFLINE`, форматтеры `fmt_issues, fmt_issue, fmt_batch, fmt_agent`. + Клиент берётся через `mcp_server._client()` (по умолчанию `HubClient.from_home()`; тесты подменяют). + +- [ ] **Step 1: Тесты** + +`tests/test_mcp.py`: +```python +import json +import threading + +import pytest + +from jira_hub import api, mcp_server, settings +from jira_hub.client import HubClient + + +@pytest.fixture +def live(tmp_path, monkeypatch): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_port}" + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) + monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) + yield {"agent": HubClient(base, tok["agent"])} + srv.shutdown() + + +def test_offline_message(monkeypatch): + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert mcp_server.issues() == mcp_server.OFFLINE + assert mcp_server.agent_status() == mcp_server.OFFLINE + + +def test_tools_roundtrip(live): + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23", + "assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []}) + out = mcp_server.issues(sprint="Спринт 2") + assert "A-1" in out and "Тема раз" in out and "ivanov" in out + assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе") + detail = mcp_server.issue("A-1") + assert "fact_status: В работе" in detail and "Готово" in detail + created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) + batch_id = int(created.split("#")[1].split()[0]) + assert "2 команд" in created and "мин" in created + bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}]) + assert bad.startswith("ошибка хаба 422") + cmd = live["agent"].post("/agent/claim", {})["command"] + live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) + tail = live["agent"].post("/agent/claim", {})["command"] + live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) + waited = mcp_server.batch_wait(batch_id, timeout_minutes=1) + assert "done" in waited and "failed" in waited and "HTTP 400" in waited + assert f"#{batch_id}" in mcp_server.batches(limit=5) + assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) + assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) + r = mcp_server.refresh(full=True, wait=False) + assert "Обновление" in r or "#" in r + assert "онлайн" in mcp_server.agent_status() + assert "A-1" in mcp_server.sync_log(limit=3) or "keys" in mcp_server.sync_log(limit=3) + assert "120" in mcp_server.settings_get("pacing") + assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) + assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422") + + +def test_formatters(): + assert mcp_server.fmt_issues([]) == "задач нет" + text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, + "sprint": None, "fact_status": None}]) + assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text + assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, + "rate_limited_24h": 0, "stuck": [], "warning": None, "last_seen_at": None, + "settings_version": None}) +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_mcp.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.mcp_server'` + +- [ ] **Step 3: Реализация** + +`src/jira_hub/mcp_server.py`: +```python +"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). +Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" +import functools +import json +import time + +from mcp.server.fastmcp import FastMCP + +from .client import HubClient, HubError, HubUnavailable + +OFFLINE = "хаб не запущен: jira-hub serve" +POLL_SECONDS = 5 +AUTHOR = "claude" +mcp = FastMCP("jira-hub") + + +def _client() -> HubClient: + return HubClient.from_home() + + +def guard(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except HubUnavailable: + return OFFLINE + except HubError as e: + return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + return wrapper + + +# --- форматирование -------------------------------------------------------------- + +def _cut(text, n=80): + text = (text or "").replace("\n", " ") + return text if len(text) <= n else text[: n - 1] + "…" + + +def _v(value): + return "—" if value in (None, "", []) else str(value) + + +def fmt_issues(issues) -> str: + if not issues: + return "задач нет" + lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"] + for i in issues: + lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")), + _v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))])) + return "\n".join(lines) + + +def fmt_issue(d) -> str: + head = [f"{d['key']} — {d.get('summary')}", + f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " + f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}", + f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; " + f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"] + links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \ + [f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])] + note = d.get("note") or {} + notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] + cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] + return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"]) + + ["описание:", d.get("description") or "—"]) + + +def fmt_batch(b) -> str: + est = round((b.get("remaining_seconds") or 0) / 60) + lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; " + + ", ".join(f"{k}: {v}" for k, v in (b.get("counts") or {}).items())] + for c in b.get("commands", []): + lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}" + + (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else "")) + return "\n".join(lines) + + +def fmt_agent(a) -> str: + state = "онлайн" if a.get("online") else "офлайн" + cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" + out = [f"агент {state} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(a.get('last_seen_at'))}); {cur}; " + f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"] + if a.get("warning"): + out.append("внимание: " + a["warning"]) + for s in a.get("stuck", []): + out.append(f"нет ответа от агента: #{s['id']} {s['type']} с {s['claimed_at']}") + return "\n".join(out) + + +# --- инструменты ----------------------------------------------------------------- + +@mcp.tool() +@guard +def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", + label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str: + """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, + подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" + data = _client().get("/issues", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, + status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit) + return fmt_issues(data["issues"]) + + +@mcp.tool() +@guard +def issue(key: str) -> str: + """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" + return fmt_issue(_client().get(f"/issues/{key}")) + + +@mcp.tool() +@guard +def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, + rest_text: str | None = None, owner_note: str | None = None) -> str: + """Правка служебных заметок задачи (только переданные поля).""" + fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text, + rest_text=rest_text, owner_note=owner_note).items() if v is not None} + fields["updated_by"] = AUTHOR + note = _client().put(f"/issues/{key}/note", fields) + return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) + + +@mcp.tool() +@guard +def batch_create(title: str, commands: list[dict]) -> str: + """Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status| + ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам.""" + b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands}) + return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" + + +@mcp.tool() +@guard +def batch_status(id: int) -> str: + """Состояние пакета и его команд.""" + return fmt_batch(_client().get(f"/batches/{id}")) + + +@mcp.tool() +@guard +def batch_wait(id: int, timeout_minutes: int = 10) -> str: + """Ждать завершения пакета (до 60 мин), вернуть итог по командам.""" + deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 + while True: + b = _client().get(f"/batches/{id}") + if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: + return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)") + time.sleep(POLL_SECONDS) + + +@mcp.tool() +@guard +def batches(limit: int = 10) -> str: + """Последние пакеты со статусом.""" + items = _client().get("/batches", limit=limit)["batches"] + return "\n".join(fmt_batch(b) for b in items) or "пакетов нет" + + +@mcp.tool() +@guard +def command_retry(id: int) -> str: + """Повторить команду — новый пакет с теми же параметрами.""" + b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR}) + return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд" + + +@mcp.tool() +@guard +def batch_cancel(id: int) -> str: + """Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API).""" + return fmt_batch(_client().post(f"/batches/{id}/cancel", {})) + + +@mcp.tool() +@guard +def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = True) -> str: + """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). wait — дождаться результата.""" + body = {"full": True} if full or not keys else {"keys": keys} + body["author"] = AUTHOR + b = _client().post("/refresh", body) + return batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" + + +@mcp.tool() +@guard +def agent_status() -> str: + """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа.""" + return fmt_agent(_client().get("/agent")) + + +@mcp.tool() +@guard +def sync_log(limit: int = 10) -> str: + """Журнал обновлений зеркала.""" + runs = _client().get("/sync-runs", limit=limit)["runs"] + return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, " + f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}" + for r in runs) or "обновлений не было" + + +@mcp.tool() +@guard +def settings_get(key: str = "") -> str: + """Настройки хаба (все или одна по ключу).""" + data = _client().get("/settings") + return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1) + + +@mcp.tool() +@guard +def settings_set(key: str, value: str) -> str: + """Записать настройку: value — JSON. Поднимает settings_version.""" + out = _client().put("/settings", {key: json.loads(value)}) + return f"settings_version = {out['settings_version']}" + + +def run() -> None: + mcp.run() +``` + +- [ ] **Step 4: Запустить — зелёный** + +Run: `uv run pytest tests/test_mcp.py -v` +Expected: 3 passed. Если FastMCP ругается на `list[dict]` в `batch_create` — заменить аннотацию на `list[dict[str, object]]`. + +- [ ] **Step 5: Коммит** + +```bash +git add src/jira_hub/mcp_server.py tests/test_mcp.py +git commit -m "feat(hub): MCP-сервер — инструменты поверх HTTP API + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 9: CLI, README, CLAUDE.md, ARCHITECTURE.md + +**Files:** +- Create: `src/jira_hub/cli.py`, `README.md` +- Modify: `CLAUDE.md`, `ARCHITECTURE.md` (заполнить шаблон обвязки) +- Test: `tests/test_cli.py` + +**Interfaces:** +- Produces: `jira-hub init --config PATH [--force]`, `jira-hub serve [--host] [--port]`, `jira-hub import FOLDER`, `jira-hub mcp`, `jira-hub tokens`; `cli.main(argv=None) -> int`. + +- [ ] **Step 1: Тест** + +`tests/test_cli.py`: +```python +import json +from pathlib import Path + +from jira_hub import cli, db, paths, settings + +FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" + + +def test_init_import_tokens(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + assert cli.main(["init", "--config", str(FIXTURE)]) == 0 + out = capsys.readouterr().out + assert "transitions_map" in out and "агентский токен" in out + assert cli.main(["init", "--config", str(FIXTURE)]) == 1 + assert "уже инициализирован" in capsys.readouterr().err + conn = db.connect(paths.db_path()) + assert settings.get(conn, "board_id") == 4216 + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8") + assert cli.main(["import", str(tmp_path / "pkg")]) == 0 + rep = json.loads(capsys.readouterr().out) + assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"] + assert cli.main(["tokens"]) == 0 + assert settings.tokens(conn)["client"] in capsys.readouterr().out +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_cli.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.cli'` + +- [ ] **Step 3: cli.py** + +`src/jira_hub/cli.py`: +```python +"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens.""" +import argparse +import json +import sys + +from . import db, importer, paths, settings + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб") + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены") + p.add_argument("--config", required=True) + p.add_argument("--force", action="store_true") + p = sub.add_parser("serve", help="запустить HTTP API") + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int) + p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") + p.add_argument("folder") + sub.add_parser("mcp", help="MCP-сервер по stdio") + sub.add_parser("tokens", help="показать токены агента и клиента") + args = ap.parse_args(argv) + + if args.cmd == "serve": + from . import api + hub = api.Hub(paths.db_path()) + conn = hub.conn() + port = args.port or int(settings.get(conn, "port") or 8790) + conn.close() + api.serve(hub, args.host, port) + return 0 + if args.cmd == "mcp": + from . import mcp_server + mcp_server.run() + return 0 + + conn = db.connect(paths.db_path()) + settings.ensure_defaults(conn) + try: + if args.cmd == "init": + with open(args.config, encoding="utf-8") as f: + cfg = json.load(f) + try: + keys = settings.init_from_config(conn, cfg, force=args.force) + except RuntimeError as e: + print(e, file=sys.stderr) + return 1 + print("перенесено: " + ", ".join(keys)) + _print_tokens(conn) + return 0 + if args.cmd == "import": + print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1)) + return 0 + if args.cmd == "tokens": + _print_tokens(conn) + return 0 + finally: + conn.close() + return 2 + + +def _print_tokens(conn) -> None: + tok = settings.tokens(conn) + print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}") + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Запустить — зелёный, потом весь набор** + +Run: `uv run pytest -q` +Expected: все passed + +- [ ] **Step 5: README.md** + +`README.md` (содержание, написать полностью по этим пунктам, на русском): +1. Что это: хаб моста Jira (зеркало + очередь + API + MCP), ссылка на `openspec/changes/bridge-core/` и на репозиторий `jira-agent`. +2. Установка: `uv sync`; где лежат данные (`~/.local/share/jira-hub/`, `JIRA_HUB_HOME`). +3. Первый запуск: `uv run jira-hub init --config ../admin/jira-sync/config.example.json` → токены; `uv run jira-hub serve` (порт 8790, адрес Mac mini `192.168.0.106`); как передать агентский токен и адрес на виртуалку (через общую папку, в `agent.json`). +4. Импорт: `uv run jira-hub import ../admin/jira-sync`, как читать отчёт (`missing_keys`, `missing_links`) после первого `refresh full`. +5. MCP: блок для `~/.claude.json` проекта `awp-ca`: + ```json + "jira-hub": {"type": "stdio", "command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/awp-ca/jira-hub", "jira-hub", "mcp"]} + ``` + и список инструментов одной таблицей (из specs/mcp). +6. API: таблица маршрутов (из specs/hub-api) с указанием токена; пример `curl`. +7. Контракт команд: таблица типов с параметрами (из specs/commands), пример пакета JSON. +8. Эксплуатация: резервные копии (`backups/`, `backup_keep`), автообновление (`auto_refresh_minutes`), `PUT /settings`, что делать с «нет ответа от агента» (отменить с `force` или дождаться агента), launchd-plist для автозапуска `serve` (пример `~/Library/LaunchAgents/ru.aepif.jira-hub.plist` с `uv run --project … jira-hub serve`). +9. Тесты: `uv run pytest`. + +- [ ] **Step 6: CLAUDE.md и ARCHITECTURE.md** + +Заменить `{{...}}` шаблона: что это (абзац из proposal), стек (Python 3.13, uv, stdlib http.server, mcp, pytest), архитектура — +таблица модулей из раздела «Структура файлов» этого плана, команды (`uv run pytest`, `uv run jira-hub serve`), тесты +(SQLite в памяти, API на порту 0 в потоке), первая подсистема — «очередь команд» (Task 5). В ARCHITECTURE.md — те же модули +с зависимостями (`api → queue → commands/mirror/settings → db`; `mcp_server → client`; `importer → mirror/settings`), границы +(«зеркало пишет только `mirror.apply_state` по запросу агента», «хаб не знает пароль Jira», «`queue` не повторяет команды»). + +- [ ] **Step 7: Коммит** + +```bash +git add src/jira_hub/cli.py tests/test_cli.py README.md CLAUDE.md ARCHITECTURE.md +git commit -m "feat(hub): CLI, README, описание архитектуры + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 10: Сквозной прогон хаба с имитацией агента + +**Files:** +- Test: `tests/test_e2e_hub.py` + +Цель: один тест проходит весь путь «пакет с созданием задачи → выдача → подстановка ключа → состояние → подтверждение» через HTTP, как это сделает настоящий агент. + +- [ ] **Step 1: Тест** + +`tests/test_e2e_hub.py`: +```python +import threading + +import pytest + +from jira_hub import api, settings +from jira_hub.client import HubClient + + +@pytest.fixture +def live(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_port}" + yield HubClient(base, tok["agent"]), HubClient(base, tok["client"]) + srv.shutdown() + + +def test_create_link_sprint_flow(live): + agent, client = live + client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"}) + b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}}, + ]}) + assert b["count"] == 4 + seen = [] + while True: + cmd = agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] + if cmd is None: + break + seen.append((cmd["type"], cmd["params"])) + if cmd["type"] == "ensure_issue": + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"key": "A-90"}, "jira_calls": 2}) + elif cmd["type"] == "refresh": + agent.post("/agent/state", {"command_id": cmd["id"], "issues": [ + {"key": "A-90", "summary": "Новая", "duedate": "2026-10-23", "sprint": "Спринт 2", "labels": [], + "created": "2026-10-11T10:00:00.000+0300"}, + {"key": "A-1", "summary": "Старая", "labels": []}], + "links": [{"source_key": "A-90", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]}) + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) + else: + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) + assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"] + assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"] + view = client.get(f"/batches/{b['id']}") + assert view["status"] == "done" and all(c["status"] == "done" for c in view["commands"]) + issue = client.get("/issues/A-90") + assert issue["note"]["work_kind"] == "разработка" and issue["links_out"][0]["target_key"] == "A-1" + assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2 +``` + +- [ ] **Step 2: Запустить** + +Run: `uv run pytest tests/test_e2e_hub.py -v` +Expected: 1 passed (код уже написан; если падает — ошибка в Tasks 5–6, чинить там) + +- [ ] **Step 3: Коммит** + +```bash +git add tests/test_e2e_hub.py +git commit -m "test(hub): сквозной прогон очереди через HTTP с имитацией агента + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +## После merge (вне репозиториев, по чеклисту закрытия) + +Не задачи плана — действия в workspace после слияния обоих PR: + +1. `awp-ca/CLAUDE.md`, таблица репозиториев: строки `jira-hub/` (aep/jira-hub — хаб моста Jira: зеркало, очередь, API, MCP) и `jira-agent/` (aep/jira-agent — агент на виртуалке). +2. `admin/jira-sync/README.md`: в начале пометка «Заменено мостом jira-hub/jira-agent, CSV заморожены <дата первой сверки>», ссылка на оба репозитория; `admin/README.md` — строка про мост. +3. `~/.claude.json`, проект `awp-ca`: MCP `jira-hub` (блок из README хаба). +4. Ввод в строй: `jira-hub init --config admin/jira-sync/config.example.json` → `jira-hub serve` → на виртуалку `agent.py` + `agent.json` (токен из `jira-hub tokens`) → `python3 agent.py check` → `python3 agent.py` → из MCP `refresh full` → `jira-hub import admin/jira-sync` → сверить `missing_keys`/`missing_links`. +5. Артефакт просмотрщика на claude.ai не переопубликовывать (решение владельца). -- 2.47.2 From 0dcf0ac5ccd66e389995b93f7cc72c8785ed648e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:39:47 +0400 Subject: [PATCH 04/37] =?UTF-8?q?spec:=20hub-api=20=E2=80=94=20neighbor=5F?= =?UTF-8?q?keys=20=D0=B2=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/specs/hub-api/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/bridge-core/specs/hub-api/spec.md b/openspec/changes/bridge-core/specs/hub-api/spec.md index 7077863..5dc11a5 100644 --- a/openspec/changes/bridge-core/specs/hub-api/spec.md +++ b/openspec/changes/bridge-core/specs/hub-api/spec.md @@ -18,8 +18,8 @@ - `GET /agent/commands/{id}` — команда по id (для восстановления из журнала). - `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный отчёт по команде в терминальном статусе отвечает 200 без изменений. -- `POST /agent/state` `{command_id, issues: [...], links: [...], page, last_page}` — загрузка состояния пачками; - хаб обновляет зеркало и `sync_run`. +- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния + пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. - `GET /agent/known-keys` — ключи зеркала (для определения соседей). #### Scenario: Выдача с подстановкой ключа -- 2.47.2 From ff0f2d10776e3efa2c3b543f0ed71b2845b59fc1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:40:07 +0400 Subject: [PATCH 05/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 3f9ec4c..43666ab 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -64,3 +64,4 @@ Non-goals — см. proposal.md. | 2026-10-11 | Хранение базы и настроек хаба — `~/.local/share/jira-hub/` | вне git, переживает пересоздание worktree | — | | 2026-10-11 | Регистрация MCP — в `~/.claude.json` для проекта `awp-ca` (stdio, `uv run jira-hub mcp`) | как остальные MCP этого проекта | — | | 2026-10-11 | Полная синхронизация по расписанию: хаб ставит `refresh` по базовой JQL раз в 60 мин, если агент на связи и очередь пуста | свежее зеркало без участия человека | лишние чтения; интервал в настройках, 0 отключает | +| 2026-10-11 | R-1: Review Focus #5 плана ссылается на Task 7, тест `test_settings_put_rejects_protected` лежит в Task 6 — тест остаётся в Task 6 | опечатка плана; спека о месте теста молчит | нулевая | -- 2.47.2 From 81850783d44b83d9516df45a6bb2a165ddf86491 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:41:00 +0400 Subject: [PATCH 06/37] =?UTF-8?q?feat(hub):=20=D1=81=D0=BA=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0,=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D0=B0=20SQLite,=20=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- .gitignore | 3 + pyproject.toml | 22 ++ src/jira_hub/__init__.py | 2 + src/jira_hub/db.py | 139 ++++++++ src/jira_hub/paths.py | 15 + tests/test_db.py | 27 ++ uv.lock | 708 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 916 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/jira_hub/__init__.py create mode 100644 src/jira_hub/db.py create mode 100644 src/jira_hub/paths.py create mode 100644 tests/test_db.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index ca0fe32..f6da44c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ data.json tests/fixtures/*.m4a tests/fixtures/*.webm .DS_Store +.venv/ +__pycache__/ +*.sqlite* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1fdba5c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "jira-hub" +version = "0.1.0" +description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" +requires-python = ">=3.13" +dependencies = ["mcp>=1.2"] + +[project.scripts] +jira-hub = "jira_hub.cli:main" + +[dependency-groups] +dev = ["pytest>=8"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/jira_hub"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/jira_hub/__init__.py b/src/jira_hub/__init__.py new file mode 100644 index 0000000..908045b --- /dev/null +++ b/src/jira_hub/__init__.py @@ -0,0 +1,2 @@ +"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP.""" +__version__ = "0.1.0" diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py new file mode 100644 index 0000000..81a2556 --- /dev/null +++ b/src/jira_hub/db.py @@ -0,0 +1,139 @@ +"""Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version.""" +import json +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +SCHEMA_VERSION = 1 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS setting ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS issue ( + key TEXT PRIMARY KEY, + id TEXT, + issue_type TEXT, + summary TEXT, + description TEXT, + status TEXT, + priority TEXT, + assignee TEXT, + reporter TEXT, + duedate TEXT, + planned_start TEXT, + planned_end TEXT, + epic_key TEXT, + labels TEXT NOT NULL DEFAULT '[]', + sprint TEXT, + created TEXT, + jira_updated TEXT, + fetched_at TEXT NOT NULL, + neighbor INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS issue_link ( + source_key TEXT NOT NULL, + target_key TEXT NOT NULL, + link_type TEXT NOT NULL, + PRIMARY KEY (source_key, target_key, link_type) +); +CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); +CREATE TABLE IF NOT EXISTS issue_note ( + key TEXT PRIMARY KEY, + fact_status TEXT, + work_kind TEXT, + done_text TEXT, + rest_text TEXT, + owner_note TEXT, + updated_at TEXT, + updated_by TEXT +); +CREATE TABLE IF NOT EXISTS batch ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT NOT NULL, + created_at TEXT NOT NULL, + cancelled_at TEXT, + estimate_seconds INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS command ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES batch(id), + seq INTEGER NOT NULL, + type TEXT NOT NULL, + params TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + claimed_at TEXT, + finished_at TEXT, + result TEXT, + error TEXT, + jira_calls INTEGER NOT NULL DEFAULT 0, + rate_limited INTEGER NOT NULL DEFAULT 0, + pause_seconds INTEGER, + depends_on INTEGER REFERENCES command(id) +); +CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq); +CREATE TABLE IF NOT EXISTS sync_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + kind TEXT NOT NULL, + query TEXT, + issues_count INTEGER NOT NULL DEFAULT 0, + links_count INTEGER NOT NULL DEFAULT 0, + jira_calls INTEGER NOT NULL DEFAULT 0, + rate_limited INTEGER NOT NULL DEFAULT 0, + command_id INTEGER +); +CREATE TABLE IF NOT EXISTS agent_status ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_seen_at TEXT, + agent_version TEXT, + settings_version INTEGER, + current_command_id INTEGER, + last_jira_call_at TEXT +); +""" + + +def now() -> str: + """Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе.""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def connect(path) -> sqlite3.Connection: + """Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных + операций — transaction().""" + is_memory = str(path) == ":memory:" + if not is_memory: + Path(path).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False) + conn.row_factory = sqlite3.Row + if not is_memory: + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA foreign_keys = ON") + migrate(conn) + return conn + + +def migrate(conn: sqlite3.Connection) -> None: + conn.executescript(SCHEMA) + row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone() + if row is None: + conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) + conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") + + +@contextmanager +def transaction(conn: sqlite3.Connection): + """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).""" + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except BaseException: + conn.execute("ROLLBACK") + raise + else: + conn.execute("COMMIT") diff --git a/src/jira_hub/paths.py b/src/jira_hub/paths.py new file mode 100644 index 0000000..4710a24 --- /dev/null +++ b/src/jira_hub/paths.py @@ -0,0 +1,15 @@ +"""Где лежат данные хаба: каталог, база, резервные копии.""" +import os +from pathlib import Path + + +def home() -> Path: + return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub"))) + + +def db_path() -> Path: + return home() / "hub.sqlite" + + +def backups_dir() -> Path: + return home() / "backups" diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..36a0a02 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,27 @@ +from jira_hub import db + + +def test_connect_creates_schema(): + conn = db.connect(":memory:") + 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 conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "1" + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + + +def test_connect_is_idempotent(tmp_path): + path = tmp_path / "hub.sqlite" + db.connect(path).close() + conn = db.connect(path) + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + + +def test_transaction_rolls_back_on_error(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + try: + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") + raise RuntimeError("boom") + except RuntimeError: + pass + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6c144bb --- /dev/null +++ b/uv.lock @@ -0,0 +1,708 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jira-hub" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", specifier = ">=1.2" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] -- 2.47.2 From f082541566f4df03cf122bb03cfb1a08abd1ecf3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:50:46 +0400 Subject: [PATCH 07/37] =?UTF-8?q?feat(hub):=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B8,=20=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B,=20init=20=D0=B8=D0=B7=20config.example.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модуль settings: значения по умолчанию, версия настроек, защищённые ключи, перенос проектных настроек из config.example.json (учётные данные остаются у агента). Фикстура tests/fixtures/config.example.json — копия admin/jira-sync/config.example.json. .pytest_cache/ добавлен в .gitignore. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- .gitignore | 1 + src/jira_hub/settings.py | 126 ++++++++++++++++++++++++++ tests/fixtures/config.example.json | 136 +++++++++++++++++++++++++++++ tests/test_settings.py | 55 ++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 src/jira_hub/settings.py create mode 100644 tests/fixtures/config.example.json create mode 100644 tests/test_settings.py diff --git a/.gitignore b/.gitignore index f6da44c..a9d9a38 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ tests/fixtures/*.webm .venv/ __pycache__/ *.sqlite* +.pytest_cache/ diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py new file mode 100644 index 0000000..8093fe1 --- /dev/null +++ b/src/jira_hub/settings.py @@ -0,0 +1,126 @@ +"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version, +агент перечитывает настройки, когда видит новую версию в ответе claim.""" +import json +import secrets + +DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, + "ensure_link": 30, "ensure_sprint": 30, "refresh": 2} + +DEFAULTS = { + "base_jql": "project = AURORA AND labels = AWPCA", + "project_key": "AURORA", + "pacing": DEFAULT_PACING, + "backup_keep": 30, + "auto_refresh_minutes": 60, + "min_agent_version": 1, + "port": 8790, + "settings_version": 1, + "initialized": False, + "issue_types": {}, + "epic_name_field_id": None, + "field_ids": {}, + "epic_link": {}, + "priority_map": {}, + "priority_value_key": "name", + "labels": [], + "status_labels": {}, + "kind_labels": {}, + "comment_marker": "", + "transitions_map": {}, + "link_types": {}, + "board_id": None, +} + +# Что получает агент (без токенов и служебных ключей хаба) +AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", + "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", + "transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"] + +PROTECTED = {"settings_version", "schema_version", "tokens"} + + +def ensure_defaults(conn) -> None: + for key, value in DEFAULTS.items(): + conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) + if get(conn, "tokens") is None: + conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)", + (_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),)) + + +def _dump(value) -> str: + return json.dumps(value, ensure_ascii=False) + + +def get(conn, key, default=None): + row = conn.execute("SELECT value FROM setting WHERE key = ?", (key,)).fetchone() + return default if row is None else json.loads(row["value"]) + + +def get_all(conn, hide_tokens=True) -> dict: + out = {r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM setting ORDER BY key")} + if hide_tokens: + out.pop("tokens", None) + return out + + +def set_(conn, key, value, bump=True) -> int: + """Записать настройку; bump=True поднимает settings_version. Защищённые ключи снаружи не меняются.""" + if key in PROTECTED: + raise ValueError(f"настройка «{key}» не редактируется") + conn.execute("INSERT INTO setting(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, _dump(value))) + if bump: + version = get(conn, "settings_version", 1) + 1 + conn.execute("UPDATE setting SET value = ? WHERE key = 'settings_version'", (_dump(version),)) + return version + return get(conn, "settings_version", 1) + + +def for_agent(conn) -> dict: + return {key: get(conn, key) for key in AGENT_KEYS} + + +def tokens(conn) -> dict: + return get(conn, "tokens") + + +def pacing(conn) -> dict: + return {**DEFAULT_PACING, **(get(conn, "pacing") or {})} + + +def pacing_from_config(p: dict) -> dict: + """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" + issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) + return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, + "ensure_link": comment, "ensure_sprint": comment, "refresh": 2} + + +def init_from_config(conn, cfg: dict, force=False) -> list[str]: + """Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira + (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают.""" + if get(conn, "initialized") and not force: + raise RuntimeError("хаб уже инициализирован; повторный init — с --force") + project = cfg["project_key"] + values = { + "project_key": project, + "base_jql": f"project = {project} AND labels = AWPCA", + "issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")}, + "epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}}, + "epic_name_field_id": cfg.get("epic_name_field_id"), + "field_ids": cfg.get("fields", {}), + "epic_link": cfg.get("epic_link", {}), + "priority_map": cfg.get("priority_map", {}), + "priority_value_key": cfg.get("priority_value_key", "name"), + "labels": cfg.get("labels", []), + "status_labels": cfg.get("status_labels", {}), + "kind_labels": cfg.get("kind_labels", {}), + "comment_marker": cfg.get("comment_marker", ""), + "transitions_map": cfg.get("transitions", {}).get("map", {}), + "link_types": cfg.get("link_types", {}), + "board_id": cfg.get("sprints", {}).get("board_id"), + "pacing": pacing_from_config(cfg.get("pacing", {})), + } + for key, value in values.items(): + set_(conn, key, value, bump=False) + set_(conn, "initialized", True) + return sorted(values) diff --git a/tests/fixtures/config.example.json b/tests/fixtures/config.example.json new file mode 100644 index 0000000..dde8a72 --- /dev/null +++ b/tests/fixtures/config.example.json @@ -0,0 +1,136 @@ +{ + "base_url": "https://sdlc.go.rshbank.ru/jira", + "auth": { + "type": "basic", + "user": "ваш_логин", + "password": "ваш_пароль", + "token": "", + "token_env": "JIRA_TOKEN", + "user_env": "JIRA_USER", + "password_env": "JIRA_PASSWORD" + }, + "verify_ssl": true, + "ca_bundle": null, + "timeout": 60, + "project_key": "AURORA", + "issue_type_task": "Задача", + "issue_type_task_id": "10100", + "issue_type_epic": "Фича", + "issue_type_epic_id": "12300", + "epic_name_field_id": "customfield_15304", + "fields": { + "description": "description", + "priority": "priority", + "duedate": "duedate", + "labels": "labels", + "planned_start": "customfield_10801", + "planned_end": "customfield_10802" + }, + "priority_value_key": "name", + "extra_fields_task": {}, + "extra_fields_epic": {}, + "epic_link": { + "mode": "field", + "field_id": "customfield_15302" + }, + "priority_map": { + "Наивысший": "Критичный", + "Высокий": "Высокий", + "Средний": "Средний", + "Низкий": "Низкий", + "Самый низкий": "Низкий" + }, + "labels": [ + "AWPCA", + "actualization-2026-10-10" + ], + "status_labels": { + "Готово — закрыть": "fact-done", + "Частично — переформулировать": "fact-partial", + "В работе": "fact-in-progress", + "Ожидает решения владельца/заказчика": "fact-decision", + "К актуализации аналитики": "fact-docs", + "Ожидание разработки": "fact-open", + "План развития (вне этапа)": "fact-roadmap", + "Снято": "fact-cancelled" + }, + "kind_labels": { + "аналитика": "kind-analytics", + "решение": "kind-decision", + "актуализация": "kind-docs", + "разработка": "kind-dev", + "UI": "kind-ui", + "тестирование": "kind-test", + "организационная": "kind-org" + }, + "update_description": true, + "comment_marker": "Актуализация 10.10.2026", + "pacing": { + "issue_seconds": 120, + "comment_seconds": 30 + }, + "transitions": { + "enabled": true, + "map": { + "Готово — закрыть": { + "default": [ + "Разработка", + "Готово к тестированию" + ], + "аналитика": [ + "Разработка", + "Готово" + ], + "решение": [ + "Разработка", + "Готово" + ], + "актуализация": [ + "Разработка", + "Готово" + ], + "организационная": [ + "Разработка", + "Готово" + ] + }, + "В работе": { + "default": "Разработка", + "аналитика": [ + "Ожидает анализа", + "Анализ" + ], + "решение": [ + "Ожидает анализа", + "Анализ" + ], + "актуализация": [ + "Ожидает анализа", + "Анализ" + ] + }, + "Ожидает решения владельца/заказчика": "Ожидает анализа", + "К актуализации аналитики": "Ожидает анализа", + "Ожидание разработки": { + "аналитика": "Ожидает анализа", + "решение": "Ожидает анализа", + "актуализация": "Ожидает анализа" + }, + "Частично — переформулировать": { + "аналитика": "Ожидает анализа", + "решение": "Ожидает анализа", + "актуализация": "Ожидает анализа" + }, + "Снято": "Снято" + } + }, + "link_types": { + "блокирует начало": "ASUPR_Блокер_Начала", + "блокирует завершение": "ASUPR_Блокер_Завершения", + "связанные": "ASUPR_Связанные" + }, + "sprints": { + "board_id": 4216, + "_comment": "id scrum-доски проекта для команды sprints (SCRUM_AWPCA); null — единственная доска проекта, при нескольких скрипт просит указать" + } +} diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..6ec2466 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,55 @@ +import json +from pathlib import Path + +import pytest + +from jira_hub import db, settings + +FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" + + +def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + return conn + + +def test_defaults_and_tokens(): + conn = fresh() + assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" + assert settings.get(conn, "pacing")["ensure_fields"] == 120 + tok = settings.tokens(conn) + assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"] + assert "tokens" not in settings.get_all(conn) + + +def test_set_bumps_version(): + conn = fresh() + v0 = settings.get(conn, "settings_version") + v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) + assert v1 == v0 + 1 + assert settings.get(conn, "pacing") == {"ensure_fields": 5} + + +@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"]) +def test_protected_keys(key): + conn = fresh() + with pytest.raises(ValueError): + settings.set_(conn, key, "x") + + +def test_init_from_config(): + conn = fresh() + cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) + keys = settings.init_from_config(conn, cfg) + assert "transitions_map" in keys + assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" + assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} + assert settings.get(conn, "board_id") == 4216 + assert settings.get(conn, "pacing") == settings.DEFAULT_PACING + agent_view = settings.for_agent(conn) + assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view + assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные" + with pytest.raises(RuntimeError): + settings.init_from_config(conn, cfg) + settings.init_from_config(conn, cfg, force=True) -- 2.47.2 From e981d6262073fcf06187c1a2a2e06a27125b4467 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:53:13 +0400 Subject: [PATCH 08/37] =?UTF-8?q?feat(hub):=20=D0=B7=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87,=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B8,=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5?= =?UTF-8?q?=D0=B1=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/mirror.py | 114 +++++++++++++++++++++++++++++++++++++++++ tests/test_mirror.py | 71 +++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/jira_hub/mirror.py create mode 100644 tests/test_mirror.py diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py new file mode 100644 index 0000000..cd97480 --- /dev/null +++ b/src/jira_hub/mirror.py @@ -0,0 +1,114 @@ +"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note). +Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" +import json + +from . import db + +ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", + "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] +NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] + +_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", + "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} + + +def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None) -> dict: + """Upsert задач; связи с source_key среди присланных задач заменяются присланными. + Связи, у которых источник не прислан, не трогаются до загрузки их источника.""" + fetched_at = fetched_at or db.now() + neighbors = set(neighbor_keys) + keys = [] + with db.transaction(conn): + for it in issues: + row = {c: it.get(c) for c in ISSUE_COLUMNS} + row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False) + row["fetched_at"] = fetched_at + row["neighbor"] = 1 if it["key"] in neighbors else 0 + cols = list(row) + conn.execute( + f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " + "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols if c != "key"), + [row[c] for c in cols]) + keys.append(it["key"]) + conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) + for link in links: + conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", + (link["source_key"], link["target_key"], link["link_type"])) + return {"issues": len(keys), "links": len(links)} + + +def known_keys(conn) -> list[str]: + return [r["key"] for r in conn.execute("SELECT key FROM issue ORDER BY key")] + + +def _issue_dict(row) -> dict: + d = dict(row) + d["labels"] = json.loads(d.get("labels") or "[]") + d["neighbor"] = bool(d.get("neighbor")) + return d + + +def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: + filters = filters or {} + where, args = [], [] + for name, sql in _FILTERS.items(): + if filters.get(name) not in (None, ""): + where.append(sql) + args.append(filters[name]) + if filters.get("label"): + where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)") + args.append(filters["label"]) + if filters.get("neighbor") not in (None, ""): + where.append("i.neighbor = ?") + args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0) + if filters.get("q"): + where.append("(i.summary LIKE ? OR i.key LIKE ?)") + args += [f"%{filters['q']}%", f"%{filters['q']}%"] + sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + + (" WHERE " + " AND ".join(where) if where else "") + + " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?") + return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])] + + +def get_issue(conn, key) -> dict | None: + row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone() + if row is None: + return None + d = _issue_dict(row) + d["links_out"] = [dict(r) for r in conn.execute( + "SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))] + d["links_in"] = [dict(r) for r in conn.execute( + "SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))] + d["note"] = get_note(conn, key) + d["commands"] = [dict(r) for r in conn.execute( + "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", + (f'%"{key}"%',))] + return d + + +def get_note(conn, key) -> dict | None: + row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone() + return dict(row) if row else None + + +def set_note(conn, key, fields: dict, updated_by: str) -> dict: + unknown = set(fields) - set(NOTE_FIELDS) + if unknown: + raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}") + cur = get_note(conn, key) or {"key": key} + cur.update(fields) + cur["updated_at"], cur["updated_by"] = db.now(), updated_by + cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"] + conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " + "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]), + [cur.get(c) for c in cols]) + return get_note(conn, key) + + +def rename_note(conn, old_key, new_key) -> None: + """После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом, + если вдруг была, уступает место).""" + if get_note(conn, old_key) is None: + return + conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) + conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) diff --git a/tests/test_mirror.py b/tests/test_mirror.py new file mode 100644 index 0000000..d281b5c --- /dev/null +++ b/tests/test_mirror.py @@ -0,0 +1,71 @@ +import pytest + +from jira_hub import db, mirror + + +def issue(key, **over): + base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст", + "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me", + "duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350", + "labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300", + "jira_updated": "2026-10-11T12:00:00.000+0300"} + base.update(over) + return base + + +def test_apply_state_upserts_and_replaces_links(): + conn = db.connect(":memory:") + links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}] + out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links) + assert out == {"issues": 2, "links": 1} + mirror.apply_state(conn, [issue("A-1", summary="Новая тема")], + [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + got = mirror.get_issue(conn, "A-1") + assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"] + assert got["links_out"] == [{"target_key": "A-2", "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(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [issue("A-1"), issue("A-2")], + [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) + 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_Связанные"}] + + +def test_apply_state_marks_neighbors(): + conn = db.connect(":memory:") + 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, "A-1")["neighbor"] is False + mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL + assert mirror.get_issue(conn, "B-9")["neighbor"] is False + assert mirror.known_keys(conn) == ["A-1", "B-9"] + + +def test_note_survives_refresh_and_renames(): + conn = db.connect(":memory:") + mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") + mirror.rename_note(conn, "new:7", "A-5") + mirror.apply_state(conn, [issue("A-5")], []) + got = mirror.get_issue(conn, "A-5") + assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import" + assert mirror.get_note(conn, "new:7") is None + with pytest.raises(ValueError): + mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x") + + +def test_list_issues_filters(): + conn = db.connect(":memory:") + 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-3", sprint="Спринт 2", duedate=None)], []) + mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x") + keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})] + assert keys == ["A-1", "A-3"] # без срока — последней + assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"] + assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"] + assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"] + assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None + assert len(mirror.list_issues(conn, None, limit=1)) == 1 -- 2.47.2 From 3394ddcf4cd8363d7a52da73c63bec66facc922d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:57:25 +0400 Subject: [PATCH 09/37] =?UTF-8?q?fix(hub):=20=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B2=20set=5Fnote=20?= =?UTF-8?q?=D0=B8=20rename=5Fnote,=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B0=D0=B2=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rename_note: при old_key == new_key ранний выход (DELETE раньше стирал единственную заметку); DELETE и UPDATE выполняются в транзакции. set_note: чтение, слияние и запись в одной транзакции (соединение общее для потоков). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/mirror.py | 24 ++++++++++++++---------- tests/test_mirror.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index cd97480..a8961ef 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -95,20 +95,24 @@ def set_note(conn, key, fields: dict, updated_by: str) -> dict: unknown = set(fields) - set(NOTE_FIELDS) if unknown: raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}") - cur = get_note(conn, key) or {"key": key} - cur.update(fields) - cur["updated_at"], cur["updated_by"] = db.now(), updated_by - cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"] - conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " - "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]), - [cur.get(c) for c in cols]) + with db.transaction(conn): # чтение, слияние и запись — атомарно (соединение общее для потоков) + cur = get_note(conn, key) or {"key": key} + cur.update(fields) + cur["updated_at"], cur["updated_by"] = db.now(), updated_by + cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"] + conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " + "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]), + [cur.get(c) for c in cols]) return get_note(conn, key) def rename_note(conn, old_key, new_key) -> None: """После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом, если вдруг была, уступает место).""" - if get_note(conn, old_key) is None: + if old_key == new_key: return - conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) - conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) + with db.transaction(conn): + if get_note(conn, old_key) is None: + return + conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) + conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) diff --git a/tests/test_mirror.py b/tests/test_mirror.py index d281b5c..d825f62 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -69,3 +69,26 @@ def test_list_issues_filters(): assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"] assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None assert len(mirror.list_issues(conn, None, limit=1)) == 1 + + +def test_rename_note_same_key_keeps_note(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [issue("A-1")], []) + mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x") + mirror.rename_note(conn, "A-1", "A-1") + assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе" + + +def test_rename_note_missing_is_noop(): + conn = db.connect(":memory:") + mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает + assert mirror.get_note(conn, "new:404") is None + assert mirror.get_note(conn, "A-9") is None + + +def test_set_note_partial_keeps_other_fields(): + conn = db.connect(":memory:") + mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x") + got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y") + assert got["work_kind"] == "разработка" and got["done_text"] == "сделано" + assert got["fact_status"] == "Готово" and got["updated_by"] == "y" -- 2.47.2 From 8314f2fb949b4e9dd6f0be159ec584596eafe952 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:59:26 +0400 Subject: [PATCH 10/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 43666ab..1557c56 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -65,3 +65,4 @@ Non-goals — см. proposal.md. | 2026-10-11 | Регистрация MCP — в `~/.claude.json` для проекта `awp-ca` (stdio, `uv run jira-hub mcp`) | как остальные MCP этого проекта | — | | 2026-10-11 | Полная синхронизация по расписанию: хаб ставит `refresh` по базовой JQL раз в 60 мин, если агент на связи и очередь пуста | свежее зеркало без участия человека | лишние чтения; интервал в настройках, 0 отключает | | 2026-10-11 | R-1: Review Focus #5 плана ссылается на Task 7, тест `test_settings_put_rejects_protected` лежит в Task 6 — тест остаётся в Task 6 | опечатка плана; спека о месте теста молчит | нулевая | +| 2026-10-11 | R-2: T3.9 (транзакции на общем соединении между потоками) — не дефект; в `set_note` правится только вводящий в заблуждение комментарий; в Task 6 проверить, что API открывает соединение на запрос | Architecture плана: каждый запрос открывает своё соединение SQLite (WAL, busy_timeout) | если соединение будут делить потоки — ошибки вложенного BEGIN; чинится локом в db.transaction | -- 2.47.2 From a19c1ca7e1990140803e62409e6d4da19954a5fa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:00:08 +0400 Subject: [PATCH 11/37] =?UTF-8?q?feat(hub):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=20=E2=80=94=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F,=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8,=20=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0,=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/commands.py | 140 +++++++++++++++++++++++++++++++++++++++ tests/test_commands.py | 88 ++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/jira_hub/commands.py create mode 100644 tests/test_commands.py diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py new file mode 100644 index 0000000..8dfcdd5 --- /dev/null +++ b/src/jira_hub/commands.py @@ -0,0 +1,140 @@ +"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md). +Здесь валидация, работа с ключами (в том числе временными new:), оценка длительности и +проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" +import re + +from . import mirror + +TEMP_KEY = re.compile(r"^new:\d+$") +FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"] + +SPEC = { + "ensure_issue": {"required": ["temp_key", "issue_type", "summary"], + "optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels", + "epic_key", "assignee"], + "keys": ["temp_key", "epic_key"]}, + "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], + "keys": ["key", "epic_key"]}, + "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]}, + "ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]}, + "ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]}, + "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]}, + "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]}, +} +WRITE_TYPES = [t for t in SPEC if t != "refresh"] + + +class CommandError(ValueError): + """Ошибка валидации пакета — наружу уходит как 422.""" + + +def is_temp(key) -> bool: + return isinstance(key, str) and bool(TEMP_KEY.match(key)) + + +def referenced_keys(cmd) -> list[str]: + out = [] + for field in SPEC[cmd["type"]]["keys"]: + value = cmd["params"].get(field) + if isinstance(value, list): + out += [v for v in value if isinstance(v, str) and v] + elif isinstance(value, str) and value: + out.append(value) + return out + + +def temp_keys_used(cmd) -> list[str]: + """Чужие временные ключи (свой temp_key у ensure_issue — не зависимость).""" + own = cmd["params"].get("temp_key") if cmd["type"] == "ensure_issue" else None + return [k for k in referenced_keys(cmd) if is_temp(k) and k != own] + + +def validate(commands) -> list[dict]: + if not isinstance(commands, list) or not commands: + raise CommandError("пакет без команд") + defined, out = set(), [] + for i, cmd in enumerate(commands, 1): + if not isinstance(cmd, dict) or "type" not in cmd: + raise CommandError(f"команда {i}: нет type") + ctype, params = cmd["type"], cmd.get("params") or {} + if ctype not in SPEC: + raise CommandError(f"команда {i}: неизвестный тип «{ctype}»") + if not isinstance(params, dict): + raise CommandError(f"команда {i}: params не объект") + spec = SPEC[ctype] + missing = [f for f in spec["required"] if params.get(f) in (None, "", [])] + if missing: + raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}") + unknown = set(params) - set(spec["required"]) - set(spec["optional"]) + if unknown: + raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}") + if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]): + raise CommandError(f"команда {i}: ensure_fields без полей") + if ctype == "refresh" and not (params.get("keys") or params.get("jql")): + raise CommandError(f"команда {i}: refresh без keys и jql") + if ctype == "ensure_issue": + if not is_temp(params["temp_key"]): + raise CommandError(f"команда {i}: temp_key должен быть new:") + if params["temp_key"] in defined: + raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен") + if params["issue_type"] not in ("task", "epic"): + raise CommandError(f"команда {i}: issue_type — task или epic") + for key in temp_keys_used({"type": ctype, "params": params}): + if key not in defined: + raise CommandError(f"команда {i}: временный ключ {key} не объявлен ранее в пакете командой ensure_issue") + if ctype == "ensure_issue": + defined.add(params["temp_key"]) + out.append({"type": ctype, "params": params}) + return out + + +def substitute(cmd_type, params, mapping: dict) -> dict: + """Подставить настоящие ключи вместо временных (temp_key самой ensure_issue не трогается).""" + out = dict(params) + for field in SPEC[cmd_type]["keys"]: + if field == "temp_key": + continue + value = out.get(field) + if isinstance(value, list): + out[field] = [mapping.get(v, v) for v in value] + elif isinstance(value, str): + out[field] = mapping.get(value, value) + return out + + +def estimate(types, pacing: dict) -> int: + return sum(int(pacing.get(t, 0)) for t in types) + + +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_comment", "refresh"): + return False, None + if ctype == "ensure_issue": + row = conn.execute("SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days') " + "ORDER BY created DESC LIMIT 1", (p["summary"],)).fetchone() + return (True, {"key": row["key"]}) if row else (False, None) + issue = mirror.get_issue(conn, p.get("key") or p.get("source")) + if issue is None: + return False, None + if ctype == "ensure_fields": + for field in FIELD_PARAMS: + if field in p and (issue.get(field) or None) != (p[field] or None): + return False, None + if any(l not in issue["labels"] for l in p.get("labels_add", [])): + return False, None + if any(l in issue["labels"] for l in p.get("labels_remove", [])): + return False, None + return True, None + if ctype == "ensure_status": + target = p["target"] + final = target[-1] if isinstance(target, list) else target + return issue["status"] == final, None + if ctype == "ensure_link": + jira_type = link_types.get(p["link_type"], p["link_type"]) + return any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"]), None + if ctype == "ensure_sprint": + want = None if p["sprint"] == "backlog" else p["sprint"] + return (issue.get("sprint") or None) == want, None + return False, None diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..263f03e --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,88 @@ +import pytest + +from jira_hub import commands, db, mirror +from jira_hub.commands import CommandError + + +def test_validate_ok_and_tail_keys(): + cmds = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "refresh", "params": {"keys": ["A-1", "new:60"]}}, + ]) + assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"] + assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"] + assert commands.temp_keys_used(cmds[1]) == ["new:60"] + assert commands.temp_keys_used(cmds[0]) == [] + assert commands.temp_keys_used(cmds[2]) == ["new:60"] + + +@pytest.mark.parametrize("bad, msg", [ + ([], "без команд"), + ([{"type": "ensure_all"}], "неизвестный тип"), + ([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"), + ([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"), + ([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"), + ([{"type": "refresh", "params": {}}], "без keys и jql"), + ([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"), + ([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"), +]) +def test_validate_errors(bad, msg): + with pytest.raises(CommandError, match=msg): + commands.validate(bad) + + +def test_substitute_and_estimate(): + p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"}) + assert p["source"] == "A-9" + p = commands.substitute("refresh", {"keys": ["new:60", "A-1"]}, {"new:60": "A-9"}) + assert p["keys"] == ["A-9", "A-1"] + p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"}, + {"new:2": "A-7", "new:60": "A-9"}) + assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7" + assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 + + +def _mirror(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", + "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", + "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov"}], + [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + return conn + + +LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"} + + +@pytest.mark.parametrize("cmd, expected", [ + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "priority": "Высокий"}}, True), + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}, False), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["kind-dev"], "labels_remove": ["fact-done"]}}, True), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["kind-dev"]}}, False), + ({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, True), + ({"type": "ensure_status", "params": {"key": "A-1", "target": "Разработка"}}, False), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, True), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "блокирует начало"}}, False), + ({"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, False), + ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True), + ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False), + ({"type": "refresh", "params": {"keys": ["A-1"]}}, False), + ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), +]) +def test_check_confirmed(cmd, expected): + ok, _ = commands.check_confirmed(_mirror(), cmd, LT) + assert ok is expected + + +def test_check_confirmed_issue_by_summary(monkeypatch): + conn = _mirror() + conn.execute("UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now') WHERE key = 'A-1'") + ok, result = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}, LT) + assert ok and result == {"key": "A-1"} + ok, _ = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Другая"}}, LT) + assert not ok -- 2.47.2 From 1fd64e974d475dc53d1c9315d6ad6580703d6bb1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:03:17 +0400 Subject: [PATCH 12/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20R-3,=20R-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 1557c56..c06a646 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -66,3 +66,5 @@ Non-goals — см. proposal.md. | 2026-10-11 | Полная синхронизация по расписанию: хаб ставит `refresh` по базовой JQL раз в 60 мин, если агент на связи и очередь пуста | свежее зеркало без участия человека | лишние чтения; интервал в настройках, 0 отключает | | 2026-10-11 | R-1: Review Focus #5 плана ссылается на Task 7, тест `test_settings_put_rejects_protected` лежит в Task 6 — тест остаётся в Task 6 | опечатка плана; спека о месте теста молчит | нулевая | | 2026-10-11 | R-2: T3.9 (транзакции на общем соединении между потоками) — не дефект; в `set_note` правится только вводящий в заблуждение комментарий; в Task 6 проверить, что API открывает соединение на запрос | Architecture плана: каждый запрос открывает своё соединение SQLite (WAL, busy_timeout) | если соединение будут делить потоки — ошибки вложенного BEGIN; чинится локом в db.transaction | +| 2026-10-11 | R-3: `commands.check_confirmed(conn, cmd, link_types, reporter=None)` — необязательный фильтр по `reporter` для `ensure_issue`; Task 5 берёт значение из настройки `reporter`, если она задана | спека требует «задача с той же темой от нас», а хаб не знает логин агента | без настройки возможно ложное `confirmed_by_state` чужой задачей с той же темой за 7 дней (только при потерянном отчёте) | +| 2026-10-11 | R-4: временный ключ `new:` должен быть объявлен `ensure_issue` раньше по списку пакета (строже спеки «вне пакета») | иначе зависимая команда с меньшим `seq` блокирует очередь | при реальной потребности в обратном порядке — снять проверку и сортировать по зависимостям | -- 2.47.2 From ccf519c9e74e41424dc708353b12e90496af3b87 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:03:59 +0400 Subject: [PATCH 13/37] =?UTF-8?q?fix(hub):=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20ensure?= =?UTF-8?q?=5Flink=20=D1=81=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D1=82=D0=B0=D0=BC=D0=B8=20=D0=B8=20ensure=5Fissue=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D1=83=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_confirmed для ensure_link: связь есть и нет конфликтов (другой тип между теми же задачами или обратное направление любого типа). check_confirmed для ensure_issue: необязательный reporter, сужает поиск по теме до задач этого автора. Тесты: конфликты, обратное направление при обоих ключах в зеркале, давность 7 дней, фильтр по reporter, выбор самой новой из двух. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/commands.py | 21 +++++++++--- tests/test_commands.py | 71 +++++++++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index 8dfcdd5..913f558 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -106,14 +106,19 @@ def estimate(types, pacing: dict) -> int: return sum(int(pacing.get(t, 0)) for t in types) -def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: - """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.""" +def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются. + reporter: если задан, ensure_issue подтверждается только задачей, заведённой этим пользователем.""" ctype, p = cmd["type"], cmd["params"] if ctype in ("ensure_comment", "refresh"): return False, None if ctype == "ensure_issue": - row = conn.execute("SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days') " - "ORDER BY created DESC LIMIT 1", (p["summary"],)).fetchone() + sql = "SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days')" + args = [p["summary"]] + if reporter is not None: + sql += " AND reporter = ?" + args.append(reporter) + row = conn.execute(sql + " ORDER BY created DESC LIMIT 1", args).fetchone() return (True, {"key": row["key"]}) if row else (False, None) issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: @@ -132,8 +137,14 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: final = target[-1] if isinstance(target, list) else target return issue["status"] == final, None if ctype == "ensure_link": + # связь есть И конфликтов нет: другой тип между теми же задачами или обратное направление любого типа jira_type = link_types.get(p["link_type"], p["link_type"]) - return any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"]), None + all_types = set(link_types.values()) + present = any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"]) + other_type = any(l["target_key"] == p["target"] and l["link_type"] in all_types - {jira_type} + for l in issue["links_out"]) + reverse = any(l["source_key"] == p["target"] and l["link_type"] in all_types for l in issue["links_in"]) + return present and not other_type and not reverse, None if ctype == "ensure_sprint": want = None if p["sprint"] == "backlog" else p["sprint"] return (issue.get("sprint") or None) == want, None diff --git a/tests/test_commands.py b/tests/test_commands.py index 263f03e..6518e5a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,6 +3,8 @@ import pytest from jira_hub import commands, db, mirror from jira_hub.commands import CommandError +NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" + def test_validate_ok_and_tail_keys(): cmds = commands.validate([ @@ -48,10 +50,12 @@ def test_substitute_and_estimate(): def _mirror(): conn = db.connect(":memory:") - mirror.apply_state(conn, [{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", - "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", - "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov"}], - [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + mirror.apply_state(conn, [ + {"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", + "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", + "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"}, + ], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) return conn @@ -79,10 +83,61 @@ def test_check_confirmed(cmd, expected): assert ok is expected -def test_check_confirmed_issue_by_summary(monkeypatch): +def test_ensure_link_reverse_direction_with_both_keys_in_mirror(): + # A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт conn = _mirror() - conn.execute("UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now') WHERE key = 'A-1'") - ok, result = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}, LT) + ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT) + assert ok is False + + +def test_ensure_link_conflict_other_type_blocks_confirmation(): + conn = _mirror() + 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) + assert ok is False + + +def test_ensure_link_conflict_reverse_blocks_confirmation(): + conn = _mirror() + 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) + assert ok is False + + +def _ensure_issue(summary): + return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}} + + +def test_check_confirmed_issue_by_summary(): + conn = _mirror() + conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") + ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT) assert ok and result == {"key": "A-1"} - ok, _ = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Другая"}}, LT) + ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT) assert not ok + + +def test_ensure_issue_older_than_7_days_not_confirmed(): + conn = _mirror() + conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") + conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'") + ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT) + assert ok is False + + +def test_ensure_issue_reporter_filter(): + conn = _mirror() + conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") + ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="ivanov") + assert ok is False + ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") + assert ok and result == {"key": "A-1"} + + +def test_ensure_issue_two_matches_takes_newest(): + conn = _mirror() + mirror.apply_state(conn, [{"key": "A-3", "summary": "Тема", "reporter": "petrov"}], []) + conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-1 day') WHERE key = 'A-1'") + conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'") + ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") + assert ok and result == {"key": "A-3"} -- 2.47.2 From 68018c3e5e37d361392b5f6b93c582e30ae70717 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:06:16 +0400 Subject: [PATCH 14/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index c06a646..6ae23a6 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -68,3 +68,4 @@ Non-goals — см. proposal.md. | 2026-10-11 | R-2: T3.9 (транзакции на общем соединении между потоками) — не дефект; в `set_note` правится только вводящий в заблуждение комментарий; в Task 6 проверить, что API открывает соединение на запрос | Architecture плана: каждый запрос открывает своё соединение SQLite (WAL, busy_timeout) | если соединение будут делить потоки — ошибки вложенного BEGIN; чинится локом в db.transaction | | 2026-10-11 | R-3: `commands.check_confirmed(conn, cmd, link_types, reporter=None)` — необязательный фильтр по `reporter` для `ensure_issue`; Task 5 берёт значение из настройки `reporter`, если она задана | спека требует «задача с той же темой от нас», а хаб не знает логин агента | без настройки возможно ложное `confirmed_by_state` чужой задачей с той же темой за 7 дней (только при потерянном отчёте) | | 2026-10-11 | R-4: временный ключ `new:` должен быть объявлен `ensure_issue` раньше по списку пакета (строже спеки «вне пакета») | иначе зависимая команда с меньшим `seq` блокирует очередь | при реальной потребности в обратном порядке — снять проверку и сортировать по зависимостям | +| 2026-10-11 | R-5: `db.transaction` становится вложенной (если `conn.in_transaction` — блок входит во внешнюю транзакцию без своих BEGIN/COMMIT) | после исправления T3.1/T3.2 `mirror.rename_note`/`set_note` сами открывают транзакцию, а `queue.report_result` по плану вызывает их внутри своей — вложенный `BEGIN IMMEDIATE` падал бы | вложенный блок не откатывается отдельно от внешнего (savepoint не нужен: ошибка откатывает всю внешнюю транзакцию) | -- 2.47.2 From f353d90ec66bdee87f1181f1c0f8d58533fec038 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:07:07 +0400 Subject: [PATCH 15/37] =?UTF-8?q?feat(hub):=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA?= =?UTF-8?q?=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B5=D0=B9,=20db.utcnow=20?= =?UTF-8?q?=D0=B8=20db.parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/db.py | 17 ++++++++++++++++- tests/test_db.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 81a2556..a3b712d 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -128,7 +128,12 @@ def migrate(conn: sqlite3.Connection) -> None: @contextmanager def transaction(conn: sqlite3.Connection): - """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).""" + """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт). + Вложенный вызов (транзакция уже открыта) входит во внешнюю: своих BEGIN/COMMIT/ROLLBACK не делает, + исключение уходит внешнему блоку, который откатит всё.""" + if conn.in_transaction: + yield conn + return conn.execute("BEGIN IMMEDIATE") try: yield conn @@ -137,3 +142,13 @@ def transaction(conn: sqlite3.Connection): raise else: conn.execute("COMMIT") + + +def utcnow() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def parse(ts: str) -> datetime: + """ISO-8601 из базы → aware datetime (naive считаем UTC).""" + d = datetime.fromisoformat(ts) + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) diff --git a/tests/test_db.py b/tests/test_db.py index 36a0a02..a402771 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -25,3 +25,34 @@ def test_transaction_rolls_back_on_error(tmp_path): except RuntimeError: pass assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_nested_transaction_joins_outer(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + try: + with db.transaction(conn): + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") + assert conn.in_transaction # вложенный блок не закоммитил сам + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 1 + raise RuntimeError("boom") + except RuntimeError: + pass + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную + + +def test_nested_transaction_error_propagates_to_outer(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + try: + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')") + with db.transaction(conn): + raise RuntimeError("inner") + except RuntimeError: + pass + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_parse_and_utcnow(): + assert db.parse("2020-01-01T00:00:00").tzinfo is not None + assert db.parse(db.now()) <= db.utcnow() -- 2.47.2 From b41ab0ad259cbaaec08dfa4df83360c001e96a55 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:07:07 +0400 Subject: [PATCH 16/37] =?UTF-8?q?feat(hub):=20=D0=BE=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=8C=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D1=8B,=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D1=87=D0=B0,=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B0,=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0,=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5,?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/backup.py | 15 +++ src/jira_hub/queue.py | 276 +++++++++++++++++++++++++++++++++++++++++ tests/test_queue.py | 201 ++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 src/jira_hub/backup.py create mode 100644 src/jira_hub/queue.py create mode 100644 tests/test_queue.py diff --git a/src/jira_hub/backup.py b/src/jira_hub/backup.py new file mode 100644 index 0000000..059634b --- /dev/null +++ b/src/jira_hub/backup.py @@ -0,0 +1,15 @@ +"""Резервная копия файла базы перед пакетом с записями в Jira: VACUUM INTO даёт согласованный снимок при WAL.""" +from datetime import datetime +from pathlib import Path + + +def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path: + backups_dir = Path(backups_dir) + backups_dir.mkdir(parents=True, exist_ok=True) + target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite" + conn.execute("VACUUM INTO ?", (str(target),)) + files = sorted(backups_dir.glob("*.sqlite")) + if keep > 0: + for old in files[:-keep]: + old.unlink() + return target diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py new file mode 100644 index 0000000..ba06d74 --- /dev/null +++ b/src/jira_hub/queue.py @@ -0,0 +1,276 @@ +"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление. +Хаб никогда не повторяет команды сам (design.md, решение 4).""" +import json +from datetime import timedelta + +from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) +from . import commands as C +from . import db, mirror, settings + +TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} +STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа» +ONLINE_MINUTES = 5 + + +class ClaimedError(Exception): + def __init__(self, claimed_at): + super().__init__(f"команда взята агентом {claimed_at}; отмена — с force") + self.claimed_at = claimed_at + + +def _cmd(row) -> dict: + d = dict(row) + d["params"] = json.loads(d["params"]) + d["result"] = json.loads(d["result"]) if d.get("result") else None + return d + + +def get_command(conn, command_id) -> dict | None: + row = conn.execute("SELECT * FROM command WHERE id = ?", (command_id,)).fetchone() + return _cmd(row) if row else None + + +def _dump(value) -> str: + return json.dumps(value, ensure_ascii=False) + + +# --- пакеты ----------------------------------------------------------------- + +def create_batch(conn, title, author, cmds) -> dict: + cmds = C.validate(cmds) + keys = [] + for c in cmds: + keys += [k for k in C.referenced_keys(c) if k not in keys] + if any(c["type"] != "refresh" for c in cmds) and keys: + cmds.append({"type": "refresh", "params": {"keys": keys}}) + est = C.estimate([c["type"] for c in cmds], settings.pacing(conn)) + with db.transaction(conn): + cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds) VALUES (?, ?, ?, ?)", + (title, author or "api", db.now(), est)) + batch_id = cur.lastrowid + defined = {} + for seq, c in enumerate(cmds, 1): + deps = [defined[k] for k in C.temp_keys_used(c) if k in defined] + cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, depends_on) VALUES (?, ?, ?, ?, ?)", + (batch_id, seq, c["type"], _dump(c["params"]), deps[0] if deps else None)) + if c["type"] == "ensure_issue": + defined[c["params"]["temp_key"]] = cur.lastrowid + return {"id": batch_id, "estimate_seconds": est, "count": len(cmds)} + + +def _status_of(cmds, cancelled_at=None) -> str: + sts = [c["status"] for c in cmds] + if not sts: + return "done" + if all(s in TERMINAL for s in sts): + return "cancelled" if cancelled_at and all(s == "cancelled" for s in sts) else "done" + if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts): + return "running" + return "queued" + + +def batch_view(conn, batch_id, with_commands=True) -> dict | None: + b = conn.execute("SELECT * FROM batch WHERE id = ?", (batch_id,)).fetchone() + if b is None: + return None + cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))] + pacing = settings.pacing(conn) + d = dict(b) + d["status"] = _status_of(cmds, b["cancelled_at"]) + d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})} + d["remaining_seconds"] = C.estimate([c["type"] for c in cmds if c["status"] in ("queued", "claimed")], pacing) + if with_commands: + d["commands"] = cmds + return d + + +def list_batches(conn, limit=20) -> list[dict]: + ids = [r["id"] for r in conn.execute("SELECT id FROM batch ORDER BY id DESC LIMIT ?", (int(limit),))] + return [batch_view(conn, i, with_commands=False) for i in ids] + + +# --- выдача ----------------------------------------------------------------- + +def _mapping(conn, batch_id) -> dict: + """temp_key → настоящий ключ по завершённым ensure_issue пакета.""" + rows = conn.execute("SELECT json_extract(params, '$.temp_key') AS t, json_extract(result, '$.key') AS k FROM command " + "WHERE batch_id = ? AND type = 'ensure_issue' AND status IN ('done', 'confirmed_by_state')", + (batch_id,)).fetchall() + return {r["t"]: r["k"] for r in rows if r["k"]} + + +def _definer_status(conn, batch_id, temp_key): + r = conn.execute("SELECT status FROM command WHERE batch_id = ? AND type = 'ensure_issue' " + "AND json_extract(params, '$.temp_key') = ?", (batch_id, temp_key)).fetchone() + return r["status"] if r else "failed" + + +def _next(conn) -> dict | None: + 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() + for row in rows: + cmd = _cmd(row) + needed = C.temp_keys_used(cmd) + if needed: + mapping = _mapping(conn, cmd["batch_id"]) + unresolved = [k for k in needed if k not in mapping] + pending = [k for k in unresolved if _definer_status(conn, cmd["batch_id"], k) not in TERMINAL] + if pending: + return None # создание ещё не завершено — ждём + cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping) + if cmd["type"] == "refresh": + cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)] + unresolved = [] if cmd["params"]["keys"] else unresolved + if unresolved: + conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", + (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) + continue + return cmd + return None + + +def _fresh_claimed_exists(conn) -> bool: + cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat() + return conn.execute("SELECT 1 FROM command WHERE status = 'claimed' AND claimed_at > ? LIMIT 1", (cutoff,)).fetchone() is not None + + +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None: + now = db.now() + to_backup = None + with db.transaction(conn): + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), " + "settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1", + (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) + if _fresh_claimed_exists(conn): + return None + cmd = _next(conn) + if cmd is None: + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") + return None + pause = int(settings.pacing(conn).get(cmd["type"], 0)) + first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", + (cmd["batch_id"],)).fetchone()["n"] == 0 + has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1", + (cmd["batch_id"],)).fetchone() is not None + conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ? WHERE id = ?", + (now, pause, _dump(cmd["params"]), cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + if cmd["type"] == "refresh": + p = cmd["params"] + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)", + (now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"])) + if first and has_writes: + to_backup = f"batch-{cmd['batch_id']}" + if to_backup and backup: + backup(to_backup) + return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]} + + +# --- отчёты ----------------------------------------------------------------- + +def record_state(conn, command_id, issues_count, links_count) -> None: + conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?", + (int(issues_count), int(links_count), command_id)) + + +def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict: + if status not in ("done", "failed"): + raise ValueError("status — done или failed") + with db.transaction(conn): + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + if cmd["status"] in TERMINAL: + return cmd # идемпотентно; cancelled не затирается + now = db.now() + conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id)) + conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?", + (now, command_id)) + if cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key"): + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + if cmd["type"] == "refresh": + conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", + (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) + if cmd["type"] == "refresh" and status == "done": + reconcile(conn) + return get_command(conn, command_id) + + +def reconcile(conn) -> list[int]: + """Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state.""" + link_types = settings.get(conn, "link_types") or {} + reporter = settings.get(conn, "reporter") + changed = [] + for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall(): + cmd = _cmd(row) + ok, result = C.check_confirmed(conn, cmd, link_types, reporter) + if not ok: + continue + with db.transaction(conn): + conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? WHERE id = ?", + (db.now(), _dump(result) if result else None, cmd["id"])) + if cmd["type"] == "ensure_issue" and result: + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + changed.append(cmd["id"]) + return changed + + +# --- отмена и повтор ---------------------------------------------------------- + +def cancel_batch(conn, batch_id) -> dict: + with db.transaction(conn): + if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None: + raise KeyError(batch_id) + now = db.now() + conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id)) + conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id)) + return batch_view(conn, batch_id) + + +def cancel_command(conn, command_id, force=False) -> dict: + with db.transaction(conn): + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + if cmd["status"] == "claimed" and not force: + raise ClaimedError(cmd["claimed_at"]) + if cmd["status"] in ("queued", "claimed"): + conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE id = ?", (db.now(), command_id)) + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (command_id,)) + return get_command(conn, command_id) + + +def retry_command(conn, command_id, author) -> dict: + cmd = get_command(conn, command_id) + if cmd is None: + raise KeyError(command_id) + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}]) + + +# --- агент и автообновление ---------------------------------------------------- + +def agent_view(conn) -> dict: + d = dict(conn.execute("SELECT * FROM agent_status WHERE id = 1").fetchone()) + now = db.utcnow() + d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES) + min_version = int(settings.get(conn, "min_agent_version") or 0) + d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None + d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?", + ((now - timedelta(days=1)).isoformat(),)).fetchone()["n"] + cutoff = (now - timedelta(minutes=STALE_MINUTES)).isoformat() + d["stuck"] = [dict(r) for r in conn.execute( + "SELECT id, type, claimed_at FROM command WHERE status = 'claimed' AND claimed_at <= ? ORDER BY id", (cutoff,))] + return d + + +def maybe_auto_refresh(conn) -> dict | None: + minutes = int(settings.get(conn, "auto_refresh_minutes") or 0) + if minutes <= 0 or not agent_view(conn)["online"]: + return None + if conn.execute("SELECT 1 FROM command WHERE status = 'queued' LIMIT 1").fetchone() or _fresh_claimed_exists(conn): + return None + last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"] + if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes): + return None + return create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": settings.get(conn, "base_jql")}}]) diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..87aea6a --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,201 @@ +import pytest + +from jira_hub import db, mirror, queue, settings +from jira_hub.commands import CommandError +from jira_hub.queue import ClaimedError + +LT = {"связанные": "ASUPR_Связанные"} + + +def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", LT) + return conn + + +def fields(key, **extra): + return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}} + + +def test_create_batch_adds_tail_refresh_and_estimate(): + conn = fresh() + 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"]) + assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"] + assert view["commands"][2]["params"] == {"keys": ["A-1", "A-2"]} + assert b["estimate_seconds"] == 120 + 30 + 2 and b["count"] == 3 + assert view["status"] == "queued" and view["author"] == "claude" + + +def test_refresh_only_batch_has_no_tail(): + conn = fresh() + b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) + assert b["count"] == 1 + with pytest.raises(CommandError): + queue.create_batch(conn, "x", "claude", [{"type": "nope"}]) + + +def test_claim_order_pause_and_report(): + conn = fresh() + b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")]) + b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")]) + c = queue.claim(conn, agent_version=1, settings_version=1) + assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"] + assert queue.claim(conn) is None # одна команда в полёте + assert queue.agent_view(conn)["current_command_id"] == c["id"] + done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2) + assert done["status"] == "done" and done["jira_calls"] == 2 + again = queue.report_result(conn, c["id"], "failed", error="поздно") + assert again["status"] == "done" # повторный отчёт не меняет + tail = queue.claim(conn) + assert tail["type"] == "refresh" and tail["pause_seconds"] == 2 + assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1" + queue.record_state(conn, tail["id"], 1, 0) + queue.report_result(conn, tail["id"], "done", jira_calls=1) + run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone() + assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1 + assert queue.batch_view(conn, b1["id"])["status"] == "done" + assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running" + + +def test_claim_is_exclusive(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + first, second = queue.claim(conn), queue.claim(conn) + assert first is not None and second is None + + +def test_stale_claimed_does_not_block(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + nxt = queue.claim(conn) + assert nxt["params"]["key"] == "A-2" + assert queue.agent_view(conn)["stuck"][0]["id"] == c["id"] + + +def test_temp_key_substitution_and_skip(): + conn = fresh() + mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") + queue.create_batch(conn, "создать", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая"}}, + {"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}}, + ]) + c1 = queue.claim(conn) + queue.report_result(conn, c1["id"], "done", result={"key": "A-90"}) + assert mirror.get_note(conn, "A-90")["fact_status"] == "В работе" and mirror.get_note(conn, "new:60") is None + c2 = queue.claim(conn) + assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90" + queue.report_result(conn, c2["id"], "done") + c3 = queue.claim(conn) + queue.report_result(conn, c3["id"], "failed", error="HTTP 400") + tail = queue.claim(conn) # ensure_sprint пропущена, refresh без new:61 + assert tail["type"] == "refresh" and tail["params"]["keys"] == ["A-90", "A-1"] + view = queue.batch_view(conn, c1["batch_id"]) + assert [c["status"] for c in view["commands"]][:4] == ["done", "done", "failed", "skipped"] + assert view["commands"][3]["error"] == "задача new:61 не создана" + + +def test_cancel_and_retry(): + conn = fresh() + b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + with pytest.raises(ClaimedError): + queue.cancel_command(conn, c["id"]) + view = queue.cancel_batch(conn, b["id"]) + assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"] + assert queue.claim(conn) is None + forced = queue.cancel_command(conn, c["id"], force=True) + assert forced["status"] == "cancelled" + after = queue.report_result(conn, c["id"], "done", result={"changed": []}) + assert after["status"] == "cancelled" and after["result"] is None # отчёт после force-отмены не затирает + assert queue.batch_view(conn, b["id"])["status"] == "cancelled" + r = queue.retry_command(conn, c["id"], "claude") + assert queue.batch_view(conn, r["id"])["title"] == f"Повтор команды {c['id']}" + assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"] + + +def test_report_after_force_cancel_keeps_cancelled(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" + + +def test_reconcile_confirms_by_state(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + queue.report_result(conn, r["id"], "done") + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" + assert r["batch_id"] == c["batch_id"] # выдался хвостовой refresh первого пакета + assert queue.batch_view(conn, c["batch_id"])["status"] == "done" + assert queue.batch_view(conn, b["id"])["status"] == "queued" + + +def test_auto_refresh_conditions(): + conn = fresh() + assert queue.maybe_auto_refresh(conn) is None # агент не на связи + queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста + b = queue.maybe_auto_refresh(conn) + assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление" + assert queue.maybe_auto_refresh(conn) is None # очередь занята + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done") + assert queue.maybe_auto_refresh(conn) is None # интервал не прошёл + conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") + assert queue.maybe_auto_refresh(conn) is not None + settings.set_(conn, "auto_refresh_minutes", 0) + conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") + assert queue.maybe_auto_refresh(conn) is None + + +def test_backup_called_for_first_write_only(tmp_path): + conn = fresh() + calls = [] + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c1 = queue.claim(conn, backup=calls.append) + queue.report_result(conn, c1["id"], "done") + queue.claim(conn, backup=calls.append) + assert calls == [f"batch-{c1['batch_id']}"] + + +def test_no_backup_for_refresh_batch(): + conn = fresh() + calls = [] + queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) + queue.claim(conn, backup=calls.append) + assert calls == [] + + +def test_make_backup_rotates(tmp_path): + from jira_hub import backup + conn = db.connect(tmp_path / "hub.sqlite") + for i in range(4): + backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) + names = sorted(p.name for p in (tmp_path / "backups").glob("*.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 + + +def test_reconcile_respects_reporter_setting(): + conn = fresh() + settings.set_(conn, "reporter", "robot") + queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + created = db.utcnow().isoformat() + mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], []) + assert queue.reconcile(conn) == [] # чужая задача не подтверждает + mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], []) + assert queue.reconcile(conn) == [c["id"]] + assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"} -- 2.47.2 From f8867939d5564b8560dc01953da8a13372040fbf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:12:19 +0400 Subject: [PATCH 17/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20R-6,=20R-7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 6ae23a6..5ec65c6 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -69,3 +69,5 @@ Non-goals — см. proposal.md. | 2026-10-11 | R-3: `commands.check_confirmed(conn, cmd, link_types, reporter=None)` — необязательный фильтр по `reporter` для `ensure_issue`; Task 5 берёт значение из настройки `reporter`, если она задана | спека требует «задача с той же темой от нас», а хаб не знает логин агента | без настройки возможно ложное `confirmed_by_state` чужой задачей с той же темой за 7 дней (только при потерянном отчёте) | | 2026-10-11 | R-4: временный ключ `new:` должен быть объявлен `ensure_issue` раньше по списку пакета (строже спеки «вне пакета») | иначе зависимая команда с меньшим `seq` блокирует очередь | при реальной потребности в обратном порядке — снять проверку и сортировать по зависимостям | | 2026-10-11 | R-5: `db.transaction` становится вложенной (если `conn.in_transaction` — блок входит во внешнюю транзакцию без своих BEGIN/COMMIT) | после исправления T3.1/T3.2 `mirror.rename_note`/`set_note` сами открывают транзакцию, а `queue.report_result` по плану вызывает их внутри своей — вложенный `BEGIN IMMEDIATE` падал бы | вложенный блок не откатывается отдельно от внешнего (savepoint не нужен: ошибка откатывает всю внешнюю транзакцию) | +| 2026-10-11 | R-6: сбой резервной копии в `queue.claim` не блокирует выдачу команды: исключение ловится, пишется предупреждение в лог | копия защищает базу хаба и не является условием записи в Jira; иначе команда повисает в `claimed` на 10 минут | при сбое диска команда выполнится без свежей копии; строгий вариант — откат claim | +| 2026-10-11 | R-7 (уточняет R-5): вложенный `db.transaction` использует `SAVEPOINT`; соединение не делится между потоками | внутренний блок, чья ошибка перехвачена вызывающим, не должен фиксировать частичные записи | лишние SAVEPOINT на каждый вложенный вызов — дёшево | -- 2.47.2 From 68b73439a671752a7d8973171587d2de494b2c71 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:12:38 +0400 Subject: [PATCH 18/37] =?UTF-8?q?fix(hub):=20=D1=81=D0=B1=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D0=B8=20=D0=BD=D0=B5=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D0=B2=D1=8B=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D1=83,=20SAVEPOINT=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B9,=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B8=D0=B9=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/db.py | 21 ++++++++++++++++++--- src/jira_hub/queue.py | 8 +++++++- tests/test_db.py | 13 +++++++++++++ tests/test_queue.py | 34 ++++++++++++++++++++++++++++++---- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index a3b712d..9b16192 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -1,4 +1,5 @@ """Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version.""" +import itertools import json import sqlite3 from contextlib import contextmanager @@ -126,13 +127,27 @@ def migrate(conn: sqlite3.Connection) -> None: conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") +_savepoints = itertools.count(1) + + @contextmanager def transaction(conn: sqlite3.Connection): """BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт). - Вложенный вызов (транзакция уже открыта) входит во внешнюю: своих BEGIN/COMMIT/ROLLBACK не делает, - исключение уходит внешнему блоку, который откатит всё.""" + Вложенный вызов (транзакция уже открыта) идёт через SAVEPOINT: при успехе RELEASE, при исключении + ROLLBACK TO + RELEASE и исключение пробрасывается — вызывающий может его перехватить, внешняя + транзакция при этом остаётся целой, а записи вложенного блока не сохраняются. + Соединение не должно делиться между потоками: каждый поток/запрос открывает своё.""" if conn.in_transaction: - yield conn + name = f"sp_{next(_savepoints)}" + conn.execute(f"SAVEPOINT {name}") + try: + yield conn + except BaseException: + conn.execute(f"ROLLBACK TO {name}") + conn.execute(f"RELEASE {name}") + raise + else: + conn.execute(f"RELEASE {name}") return conn.execute("BEGIN IMMEDIATE") try: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index ba06d74..ed45ff7 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -1,12 +1,15 @@ """Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление. Хаб никогда не повторяет команды сам (design.md, решение 4).""" import json +import logging from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import commands as C from . import db, mirror, settings +log = logging.getLogger(__name__) + TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа» ONLINE_MINUTES = 5 @@ -162,7 +165,10 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non if first and has_writes: to_backup = f"batch-{cmd['batch_id']}" if to_backup and backup: - backup(to_backup) + try: + backup(to_backup) + except Exception: # сбой копии не должен держать очередь: команда уже взята + log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True) return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]} diff --git a/tests/test_db.py b/tests/test_db.py index a402771..0133f79 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -56,3 +56,16 @@ def test_nested_transaction_error_propagates_to_outer(tmp_path): def test_parse_and_utcnow(): assert db.parse("2020-01-01T00:00:00").tzinfo is not None assert db.parse(db.now()) <= db.utcnow() + + +def test_nested_transaction_rollback_is_local(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')") + try: + with db.transaction(conn): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('inner', 'y', 'z')") + raise RuntimeError("inner") + except RuntimeError: + pass + assert [r["title"] for r in conn.execute("SELECT title FROM batch")] == ["outer"] diff --git a/tests/test_queue.py b/tests/test_queue.py index 87aea6a..fe697d3 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -59,11 +59,37 @@ def test_claim_order_pause_and_report(): assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running" -def test_claim_is_exclusive(): +def test_claim_is_exclusive(tmp_path): + import threading + path = tmp_path / "hub.sqlite" + setup = db.connect(path) + settings.ensure_defaults(setup) + queue.create_batch(setup, "x", "claude", [fields("A-1")]) + conns = [db.connect(path), db.connect(path)] + barrier, results = threading.Barrier(2), [] + + def worker(c): + barrier.wait() + results.append(queue.claim(c)) + + threads = [threading.Thread(target=worker, args=(c,)) for c in conns] + [t.start() for t in threads] + [t.join() for t in threads] + assert sorted(r is None for r in results) == [False, True] + assert setup.execute("SELECT COUNT(*) AS n FROM command WHERE status = 'claimed'").fetchone()["n"] == 1 + + +def test_backup_failure_does_not_block_claim(caplog): conn = fresh() - queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) - first, second = queue.claim(conn), queue.claim(conn) - assert first is not None and second is None + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + + def broken(label): + raise OSError("диск полон") + + c = queue.claim(conn, backup=broken) + assert c is not None and c["params"]["key"] == "A-1" + assert queue.get_command(conn, c["id"])["status"] == "claimed" + assert "не создана" in caplog.text def test_stale_claimed_does_not_block(): -- 2.47.2 From 2558248dc99d3f5c55af8f0aa7a85e5df90c3f95 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:15:59 +0400 Subject: [PATCH 19/37] =?UTF-8?q?feat(hub):=20HTTP=20API=20=E2=80=94=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=BE=D0=B2,=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D1=8B,=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8?= =?UTF-8?q?=D0=B9=20claim,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Соединение SQLite на запрос, сравнение токенов в постоянное время, 400 на битый JSON и нечисловые параметры. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 318 +++++++++++++++++++++++++++++++++++++++++ src/jira_hub/client.py | 63 ++++++++ tests/test_api.py | 153 ++++++++++++++++++++ 3 files changed, 534 insertions(+) create mode 100644 src/jira_hub/api.py create mode 100644 src/jira_hub/client.py create mode 100644 tests/test_api.py diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py new file mode 100644 index 0000000..01771fe --- /dev/null +++ b/src/jira_hub/api.py @@ -0,0 +1,318 @@ +"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). +Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" +import hmac +import json +import re +import threading +import time +import traceback +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from . import backup, db, mirror, queue, settings +from .commands import CommandError +from .paths import backups_dir as default_backups_dir +from .queue import ClaimedError + + +class ApiError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(error) + self.status, self.error, self.detail = status, error, detail + + +class Hub: + def __init__(self, db_path, backups_dir=None): + self.db_path = Path(db_path) + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.claim_wait = 30 + conn = self.conn() + settings.ensure_defaults(conn) + conn.close() + + def conn(self): + return db.connect(self.db_path) + + def make_backup(self, label): + conn = self.conn() + try: + backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30)) + finally: + conn.close() + + +# --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- + +def _int(m, i=1): + return int(m.group(i)) + + +def _not_found(obj, what): + if obj is None: + raise ApiError(404, "not_found", what) + return obj + + +def h_claim(hub, conn, m, q, body): + deadline = time.monotonic() + hub.claim_wait + while True: + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup) + if cmd or time.monotonic() >= deadline: + break + time.sleep(1) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")} + + +def h_agent_settings(hub, conn, m, q, body): + return 200, settings.for_agent(conn) + + +def h_agent_command(hub, conn, m, q, body): + return 200, _not_found(queue.get_command(conn, _int(m)), "command") + + +def h_agent_result(hub, conn, m, q, body): + try: + cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), + body.get("jira_calls", 0), body.get("rate_limited", 0)) + except KeyError: + raise ApiError(404, "not_found", "command") + except ValueError as e: + raise ApiError(422, "bad_status", str(e)) + return 200, cmd + + +def h_agent_state(hub, conn, m, q, body): + with db.transaction(conn): + out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or []) + if body.get("command_id"): + queue.record_state(conn, body["command_id"], out["issues"], out["links"]) + return 200, out + + +def h_known_keys(hub, conn, m, q, body): + return 200, {"keys": mirror.known_keys(conn)} + + +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q") + + +def h_issues(hub, conn, m, q, body): + filters = {k: q.get(k) for k in ISSUE_FILTERS} + return 200, {"issues": mirror.list_issues(conn, filters, int(q.get("limit", 50)), int(q.get("offset", 0)))} + + +def h_issue(hub, conn, m, q, body): + return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") + + +def h_note_put(hub, conn, m, q, body): + fields = {k: v for k, v in body.items() if k != "updated_by"} + try: + return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api") + except ValueError as e: + raise ApiError(422, "bad_note", str(e)) + + +def h_batches_post(hub, conn, m, q, body): + if not body.get("title"): + raise ApiError(422, "bad_batch", "нет title") + try: + return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + + +def h_batches_get(hub, conn, m, q, body): + return 200, {"batches": queue.list_batches(conn, int(q.get("limit", 20)))} + + +def h_batch_get(hub, conn, m, q, body): + return 200, _not_found(queue.batch_view(conn, _int(m)), "batch") + + +def h_batch_cancel(hub, conn, m, q, body): + try: + return 200, queue.cancel_batch(conn, _int(m)) + except KeyError: + raise ApiError(404, "not_found", "batch") + + +def h_command_retry(hub, conn, m, q, body): + try: + return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") + except KeyError: + raise ApiError(404, "not_found", "command") + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + + +def h_command_cancel(hub, conn, m, q, body): + try: + return 200, queue.cancel_command(conn, _int(m), bool(body.get("force"))) + except KeyError: + raise ApiError(404, "not_found", "command") + except ClaimedError as e: + raise ApiError(409, "claimed", e.claimed_at) + + +def h_refresh(hub, conn, m, q, body): + if body.get("full"): + params = {"jql": settings.get(conn, "base_jql")} + elif body.get("jql"): + params = {"jql": body["jql"]} + elif body.get("keys"): + params = {"keys": list(body["keys"])} + else: + raise ApiError(422, "bad_refresh", "нужно full, jql или keys") + return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) + + +def h_agent_get(hub, conn, m, q, body): + return 200, queue.agent_view(conn) + + +def h_sync_runs(hub, conn, m, q, body): + rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int(q.get("limit", 20)),)).fetchall() + return 200, {"runs": [dict(r) for r in rows]} + + +def h_settings_get(hub, conn, m, q, body): + return 200, settings.get_all(conn) + + +def h_settings_put(hub, conn, m, q, body): + bad = [k for k in body if k in settings.PROTECTED] + if bad: + raise ApiError(422, "protected", ", ".join(bad)) + version = settings.get(conn, "settings_version") + with db.transaction(conn): + for key, value in body.items(): + version = settings.set_(conn, key, value) + return 200, {"settings_version": version} + + +ROUTES = [ + ("POST", r"^/agent/claim$", h_claim, "agent"), + ("GET", r"^/agent/settings$", h_agent_settings, "agent"), + ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), + ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), + ("POST", r"^/agent/state$", h_agent_state, "agent"), + ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), + ("GET", r"^/issues$", h_issues, "client"), + ("GET", r"^/issues/([^/]+)$", h_issue, "client"), + ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), + ("POST", r"^/batches$", h_batches_post, "client"), + ("GET", r"^/batches$", h_batches_get, "client"), + ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), + ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), + ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), + ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), + ("POST", r"^/refresh$", h_refresh, "client"), + ("GET", r"^/agent$", h_agent_get, "client"), + ("GET", r"^/sync-runs$", h_sync_runs, "client"), + ("GET", r"^/settings$", h_settings_get, "client"), + ("PUT", r"^/settings$", h_settings_put, "client"), +] +_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] + + +class Handler(BaseHTTPRequestHandler): + hub: Hub = None # подставляется в make_server + + def do_GET(self): + self._dispatch("GET") + + def do_POST(self): + self._dispatch("POST") + + def do_PUT(self): + self._dispatch("PUT") + + def log_message(self, fmt, *args): # тише стандартного: без адреса клиента + print(f"[{self.log_date_time_string()}] {fmt % args}") + + def _auth(self, scope): + token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip() + conn = self.hub.conn() + try: + tokens = settings.tokens(conn) + finally: + conn.close() + expected = tokens["agent" if scope == "agent" else "client"] + if not token or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")): + raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") + + def _dispatch(self, method): + url = urlparse(self.path) + try: + query = {k: v[0] for k, v in parse_qs(url.query).items()} + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + for route_method, rx, fn, scope in _COMPILED: + if route_method != method: + continue + match = rx.match(url.path) + if not match: + continue + self._auth(scope) + try: + body = json.loads(raw.decode("utf-8")) if raw else {} + except ValueError as e: # битый JSON или не UTF-8 + raise ApiError(400, "bad_json", str(e)) + if not isinstance(body, dict): + raise ApiError(400, "bad_json", "ожидается объект") + conn = self.hub.conn() + try: + status, payload = fn(self.hub, conn, match, query, body) + except (ValueError, TypeError) as e: # нечисловой limit, неверные типы полей + raise ApiError(400, "bad_request", str(e)) + finally: + conn.close() + return self._send(status, payload) + raise ApiError(404, "not_found", url.path) + except ApiError as e: + self._send(e.status, {"error": e.error, "detail": e.detail}) + except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе + traceback.print_exc() + self._send(500, {"error": "internal", "detail": str(e)}) + + def _send(self, status, payload): + data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer: + handler = type("HubHandler", (Handler,), {"hub": hub}) + return ThreadingHTTPServer((host, port), handler) + + +def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: + srv = make_server(hub, host, port) + stop = threading.Event() + + def ticker(): + while not stop.wait(60): + conn = hub.conn() + try: + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + traceback.print_exc() + finally: + conn.close() + + threading.Thread(target=ticker, daemon=True).start() + print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}") + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + stop.set() + srv.server_close() diff --git a/src/jira_hub/client.py b/src/jira_hub/client.py new file mode 100644 index 0000000..55f356f --- /dev/null +++ b/src/jira_hub/client.py @@ -0,0 +1,63 @@ +"""HTTP-клиент хаба: для MCP, тестов и скриптов. Ошибки API → HubError, недоступный хаб → HubUnavailable.""" +import json +import urllib.error +import urllib.parse +import urllib.request + +from . import db, paths, settings + + +class HubError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(f"HTTP {status} {error}: {detail or ''}".strip()) + self.status, self.error, self.detail = status, error, detail + + +class HubUnavailable(Exception): + """Хаб не отвечает — обычно не запущен (jira-hub serve).""" + + +class HubClient: + def __init__(self, base_url: str, token: str, timeout: float = 60): + self.base, self.token, self.timeout = base_url.rstrip("/"), token, timeout + + @classmethod + def from_home(cls, timeout: float = 60) -> "HubClient": + conn = db.connect(paths.db_path()) + try: + settings.ensure_defaults(conn) + return cls(f"http://127.0.0.1:{settings.get(conn, 'port')}", settings.tokens(conn)["client"], timeout) + finally: + conn.close() + + def call(self, method: str, path: str, body=None, params=None): + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Bearer " + self.token) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as e: + try: + payload = json.loads(e.read().decode("utf-8")) + except Exception: + payload = {"error": "http", "detail": str(e)} + raise HubError(e.code, payload.get("error", "http"), payload.get("detail")) from None + except (urllib.error.URLError, ConnectionError, TimeoutError) as e: + raise HubUnavailable(f"хаб не отвечает ({self.base}): {e}") from None + + def get(self, path, **params): + return self.call("GET", path, params=params or None) + + def post(self, path, body=None): + return self.call("POST", path, body if body is not None else {}) + + def put(self, path, body): + return self.call("PUT", path, body) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0469c86 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,153 @@ +import threading + +import pytest + +from jira_hub import api, db, settings +from jira_hub.client import HubClient, HubError + + +@pytest.fixture +def hub(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + base = f"http://127.0.0.1:{srv.server_port}" + yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base} + srv.shutdown() + srv.server_close() + th.join(5) + + +def test_auth_scopes(hub): + with pytest.raises(HubError) as e: + hub["agent"].get("/issues") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["client"].post("/agent/claim", {}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + HubClient(hub["base"], "wrong").get("/issues") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["client"].get("/nope") + assert e.value.status == 404 + + +def test_agent_flow(hub): + c = hub["client"] + b = c.post("/batches", {"title": "срок", "author": "claude", + "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + assert b["count"] == 2 + a = hub["agent"] + claim = a.post("/agent/claim", {"agent_version": 1, "settings_version": 1}) + cmd = claim["command"] + assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1 + assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"] + assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные" + assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен + r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2}) + assert r["status"] == "done" + assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done" + tail = a.post("/agent/claim", {})["command"] + assert tail["type"] == "refresh" + out = a.post("/agent/state", {"command_id": tail["id"], "page": 1, "last_page": True, + "issues": [{"key": "A-1", "summary": "Т", "duedate": "2026-10-23", "labels": ["AWPCA"], + "created": "2026-10-10T10:00:00.000+0300"}, + {"key": "B-1", "summary": "Сосед", "labels": []}], + "links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}], + "neighbor_keys": ["B-1"]}) + assert out == {"issues": 2, "links": 1} + assert a.get("/agent/known-keys")["keys"] == ["A-1", "B-1"] + a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1}) + assert c.get("/batches/%d" % b["id"])["status"] == "done" + issue = c.get("/issues/A-1") + assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}] + assert c.get("/issues/B-1")["neighbor"] is True + runs = c.get("/sync-runs", limit=5)["runs"] + assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"] + assert c.get("/agent")["online"] is True + + +def test_interface_routes(hub): + c = hub["client"] + with pytest.raises(HubError) as e: + c.get("/issues/A-404") + assert e.value.status == 404 + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []}, + {"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []}) + assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"] + note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"}) + assert note["fact_status"] == "В работе" and note["updated_by"] == "owner" + with pytest.raises(HubError) as e: + c.put("/issues/A-1/note", {"summary": "x"}) + assert e.value.status == 422 + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1"}}]}) + assert e.value.status == 422 and "без полей" in e.value.detail + b = c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}, + {"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]}) + assert c.get("/batches", limit=5)["batches"][0]["id"] == b["id"] + cmd = hub["agent"].post("/agent/claim", {})["command"] + with pytest.raises(HubError) as e: + c.post(f"/commands/{cmd['id']}/cancel", {}) + assert e.value.status == 409 and e.value.detail + assert c.post(f"/commands/{cmd['id']}/cancel", {"force": True})["status"] == "cancelled" + assert c.post(f"/batches/{b['id']}/cancel", {})["status"] == "cancelled" + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}" + full = c.post("/refresh", {"full": True}) + assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels = AWPCA" + keys = c.post("/refresh", {"keys": ["A-1"]}) + assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]} + + +def test_settings_put_rejects_protected(hub): + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + for key in ("tokens", "settings_version"): + with pytest.raises(HubError) as e: + c.put("/settings", {key: "x", "pacing": {"refresh": 1}}) + assert e.value.status == 422 + assert c.get("/settings")["settings_version"] == v0 + assert "tokens" not in c.get("/settings") + v1 = c.put("/settings", {"pacing": {"refresh": 1}})["settings_version"] + assert v1 == v0 + 1 and c.get("/settings")["pacing"]["refresh"] == 1 + + +def test_backup_created_on_first_write(hub, tmp_path): + c = hub["client"] + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + hub["agent"].post("/agent/claim", {}) + assert len(list((tmp_path / "backups").glob("*-batch-*.sqlite"))) == 1 + + +def test_client_unavailable(): + from jira_hub.client import HubUnavailable + with pytest.raises(HubUnavailable): + HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues") + + +def test_no_backup_for_refresh_batch(hub, tmp_path): + hub["client"].post("/refresh", {"keys": ["A-1"]}) + assert hub["agent"].post("/agent/claim", {})["command"]["type"] == "refresh" + assert not list((tmp_path / "backups").glob("*.sqlite")) + + +def test_bad_requests(hub): + c = hub["client"] + with pytest.raises(HubError) as e: + c.get("/issues", limit="abc") + assert e.value.status == 400 + import urllib.request, urllib.error + req = urllib.request.Request(hub["base"] + "/batches", data=b"{not json", method="POST", + headers={"Authorization": "Bearer " + c.token}) + with pytest.raises(urllib.error.HTTPError) as e2: + urllib.request.urlopen(req) + assert e2.value.code == 400 + e2.value.close() -- 2.47.2 From 34d5714898c18cdf299719553deb57a13078f4c2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:19:54 +0400 Subject: [PATCH 20/37] =?UTF-8?q?fix(hub):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20Content-Length=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=20=D1=82=D0=B5=D0=BB=D0=B0,=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA,=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 49 ++++++++++++++++++++++--------- tests/test_api.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 01771fe..dd71448 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -2,6 +2,7 @@ Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" import hmac import json +import logging import re import threading import time @@ -15,6 +16,11 @@ from .commands import CommandError from .paths import backups_dir as default_backups_dir from .queue import ClaimedError +log = logging.getLogger(__name__) + +MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт +SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит + class ApiError(Exception): def __init__(self, status, error, detail=None): @@ -34,6 +40,18 @@ class Hub: def conn(self): return db.connect(self.db_path) + def tick(self): + """Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл.""" + conn = None + try: + conn = self.conn() + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + log.exception("автообновление: сбой тика") + finally: + if conn: + conn.close() + def make_backup(self, label): conn = self.conn() try: @@ -48,6 +66,13 @@ def _int(m, i=1): return int(m.group(i)) +def _int_param(q, name, default): + try: + return int(q.get(name, default)) + except (TypeError, ValueError): + raise ApiError(400, "bad_param", f"{name}: ожидается целое число") + + def _not_found(obj, what): if obj is None: raise ApiError(404, "not_found", what) @@ -101,7 +126,7 @@ ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "la def h_issues(hub, conn, m, q, body): filters = {k: q.get(k) for k in ISSUE_FILTERS} - return 200, {"issues": mirror.list_issues(conn, filters, int(q.get("limit", 50)), int(q.get("offset", 0)))} + return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))} def h_issue(hub, conn, m, q, body): @@ -126,7 +151,7 @@ def h_batches_post(hub, conn, m, q, body): def h_batches_get(hub, conn, m, q, body): - return 200, {"batches": queue.list_batches(conn, int(q.get("limit", 20)))} + return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20))} def h_batch_get(hub, conn, m, q, body): @@ -175,7 +200,7 @@ def h_agent_get(hub, conn, m, q, body): def h_sync_runs(hub, conn, m, q, body): - rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int(q.get("limit", 20)),)).fetchall() + rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (_int_param(q, "limit", 20),)).fetchall() return 200, {"runs": [dict(r) for r in rows]} @@ -220,6 +245,7 @@ _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] class Handler(BaseHTTPRequestHandler): + timeout = SOCKET_TIMEOUT hub: Hub = None # подставляется в make_server def do_GET(self): @@ -249,7 +275,12 @@ class Handler(BaseHTTPRequestHandler): url = urlparse(self.path) try: query = {k: v[0] for k, v in parse_qs(url.query).items()} - length = int(self.headers.get("Content-Length") or 0) + raw_len = (self.headers.get("Content-Length") or "0").strip() + if not raw_len.isascii() or not raw_len.isdigit(): + raise ApiError(400, "bad_content_length", "Content-Length должен быть неотрицательным целым") + length = int(raw_len) + if length > MAX_BODY: + raise ApiError(413, "too_large", f"тело больше {MAX_BODY} байт") raw = self.rfile.read(length) if length else b"" for route_method, rx, fn, scope in _COMPILED: if route_method != method: @@ -267,8 +298,6 @@ class Handler(BaseHTTPRequestHandler): conn = self.hub.conn() try: status, payload = fn(self.hub, conn, match, query, body) - except (ValueError, TypeError) as e: # нечисловой limit, неверные типы полей - raise ApiError(400, "bad_request", str(e)) finally: conn.close() return self._send(status, payload) @@ -299,13 +328,7 @@ def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: def ticker(): while not stop.wait(60): - conn = hub.conn() - try: - queue.maybe_auto_refresh(conn) - except Exception: # noqa: BLE001 - traceback.print_exc() - finally: - conn.close() + hub.tick() threading.Thread(target=ticker, daemon=True).start() print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}") diff --git a/tests/test_api.py b/tests/test_api.py index 0469c86..e404d1d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,4 @@ +import json import threading import pytest @@ -151,3 +152,72 @@ def test_bad_requests(hub): urllib.request.urlopen(req) assert e2.value.code == 400 e2.value.close() + + +# --- ревью Task 6 -------------------------------------------------------------- + +def _raw(hub, headers, path="/batches", method="POST"): + import http.client + host, port = hub["base"].removeprefix("http://").split(":") + c = http.client.HTTPConnection(host, int(port), timeout=10) + try: + c.putrequest(method, path) + c.putheader("Authorization", "Bearer " + hub["client"].token) + for k, v in headers.items(): + c.putheader(k, v) + c.endheaders() + r = c.getresponse() + return r.status, json.loads(r.read().decode("utf-8")) + finally: + c.close() + + + +@pytest.mark.parametrize("value,status,error", [("-5", 400, "bad_content_length"), ("abc", 400, "bad_content_length"), + (str(api.MAX_BODY + 1), 413, "too_large")]) +def test_content_length_checked(hub, value, status, error): + got, payload = _raw(hub, {"Content-Length": value}) + assert got == status and payload["error"] == error + + +def test_numeric_param_400_internal_bug_500(hub, monkeypatch): + with pytest.raises(HubError) as e: + hub["client"].get("/batches", limit="x") + assert e.value.status == 400 + + def boom(*a): + raise TypeError("внутренний баг") + monkeypatch.setattr(api, "_COMPILED", [("GET", api.re.compile(r"^/issues$"), boom, "client")]) + with pytest.raises(HubError) as e: + hub["client"].get("/issues") + assert e.value.status == 500 and e.value.error == "internal" + with pytest.raises(HubError) as e: # сервер жив + hub["client"].get("/issues") + assert e.value.status == 500 + + +def test_tick_survives_connection_failure(hub, monkeypatch): + def broken(): + raise OSError("диск недоступен") + monkeypatch.setattr(hub["hub"], "conn", broken) + hub["hub"].tick() # не бросает + monkeypatch.undo() + hub["hub"].tick() + + +def test_claim_long_poll_returns_new_command(hub): + import time + hub["hub"].claim_wait = 4 + threading.Timer(0.5, lambda: hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})).start() + t0 = time.monotonic() + cmd = hub["agent"].post("/agent/claim", {})["command"] + assert cmd and cmd["type"] == "ensure_fields" and time.monotonic() - t0 < 4 + + +def test_claim_long_poll_times_out_with_null(hub): + import time + hub["hub"].claim_wait = 1.5 + t0 = time.monotonic() + assert hub["agent"].post("/agent/claim", {})["command"] is None + assert 1.0 <= time.monotonic() - t0 < 4 -- 2.47.2 From 600163a9ee880c757ad5594a7cb2cf15d4405802 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:23:00 +0400 Subject: [PATCH 21/37] =?UTF-8?q?fix(hub):=20=D1=82=D0=B8=D0=BF=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20=D1=82=D0=B5=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B4=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20422,=20=D0=B0=20=D0=BD=D0=B5=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 28 ++++++++++++++++++++++------ tests/test_api.py | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index dd71448..a66b193 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -142,8 +142,12 @@ def h_note_put(hub, conn, m, q, body): def h_batches_post(hub, conn, m, q, body): - if not body.get("title"): - raise ApiError(422, "bad_batch", "нет title") + if not body.get("title") or not isinstance(body["title"], str): + raise ApiError(422, "bad_batch", "title — непустая строка") + if not isinstance(body.get("author") or "api", str): + raise ApiError(422, "bad_batch", "author — строка") + if not isinstance(body.get("commands"), list): + raise ApiError(422, "bad_batch", "commands — список команд") try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) except CommandError as e: @@ -166,6 +170,8 @@ def h_batch_cancel(hub, conn, m, q, body): def h_command_retry(hub, conn, m, q, body): + if not isinstance(body.get("author") or "api", str): + raise ApiError(422, "bad_retry", "author — строка") try: return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") except KeyError: @@ -184,12 +190,19 @@ def h_command_cancel(hub, conn, m, q, body): def h_refresh(hub, conn, m, q, body): + if not isinstance(body.get("author") or "api", str): + raise ApiError(422, "bad_refresh", "author — строка") + keys = body.get("keys") + if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)): + raise ApiError(422, "bad_refresh", "keys — список строк") + if body.get("jql") is not None and not isinstance(body["jql"], str): + raise ApiError(422, "bad_refresh", "jql — строка") if body.get("full"): params = {"jql": settings.get(conn, "base_jql")} elif body.get("jql"): params = {"jql": body["jql"]} elif body.get("keys"): - params = {"keys": list(body["keys"])} + params = {"keys": keys} else: raise ApiError(422, "bad_refresh", "нужно full, jql или keys") return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) @@ -213,9 +226,12 @@ def h_settings_put(hub, conn, m, q, body): if bad: raise ApiError(422, "protected", ", ".join(bad)) version = settings.get(conn, "settings_version") - with db.transaction(conn): - for key, value in body.items(): - version = settings.set_(conn, key, value) + try: + with db.transaction(conn): + for key, value in body.items(): + version = settings.set_(conn, key, value) + except ValueError as e: # запрет на защищённые ключи из settings.set_ + raise ApiError(422, "protected", str(e)) return 200, {"settings_version": version} diff --git a/tests/test_api.py b/tests/test_api.py index e404d1d..00c55d7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -221,3 +221,23 @@ def test_claim_long_poll_times_out_with_null(hub): t0 = time.monotonic() assert hub["agent"].post("/agent/claim", {})["command"] is None assert 1.0 <= time.monotonic() - t0 < 4 + + +@pytest.mark.parametrize("path,body", [ + ("/refresh", {"keys": 5}), ("/refresh", {"keys": "A-1"}), ("/refresh", {"keys": [1]}), ("/refresh", {"jql": 5}), + ("/batches", {"title": "x", "commands": 5}), ("/batches", {"title": "x", "commands": "x"}), + ("/batches", {"title": ["x"], "commands": []}), ("/batches", {"title": "x", "author": 5, "commands": []}), +]) +def test_bad_body_shapes_are_422(hub, path, body): + with pytest.raises(HubError) as e: + hub["client"].post(path, body) + assert e.value.status == 422 + + +def test_settings_put_invalid_is_4xx_and_atomic(hub): + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + with pytest.raises(HubError) as e: + c.put("/settings", {"pacing": {"refresh": 3}, "tokens": {}}) + assert 400 <= e.value.status < 500 + assert c.get("/settings")["settings_version"] == v0 -- 2.47.2 From a67ce091e136218a7a55623688904b9eae09a37d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:25:02 +0400 Subject: [PATCH 22/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 5ec65c6..fcd107d 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -71,3 +71,4 @@ Non-goals — см. proposal.md. | 2026-10-11 | R-5: `db.transaction` становится вложенной (если `conn.in_transaction` — блок входит во внешнюю транзакцию без своих BEGIN/COMMIT) | после исправления T3.1/T3.2 `mirror.rename_note`/`set_note` сами открывают транзакцию, а `queue.report_result` по плану вызывает их внутри своей — вложенный `BEGIN IMMEDIATE` падал бы | вложенный блок не откатывается отдельно от внешнего (savepoint не нужен: ошибка откатывает всю внешнюю транзакцию) | | 2026-10-11 | R-6: сбой резервной копии в `queue.claim` не блокирует выдачу команды: исключение ловится, пишется предупреждение в лог | копия защищает базу хаба и не является условием записи в Jira; иначе команда повисает в `claimed` на 10 минут | при сбое диска команда выполнится без свежей копии; строгий вариант — откат claim | | 2026-10-11 | R-7 (уточняет R-5): вложенный `db.transaction` использует `SAVEPOINT`; соединение не делится между потоками | внутренний блок, чья ошибка перехвачена вызывающим, не должен фиксировать частичные записи | лишние SAVEPOINT на каждый вложенный вызов — дёшево | +| 2026-10-11 | R-8: T6.16 считается закрытой по формам тела; недостижимая ветка `except ValueError` в `h_settings_put` и широкое имя теста — minor, правятся в финальной волне | protected-ключи отсекаются предпроверкой, `settings.set_` значения не валидирует | введённое в заблуждение имя теста; ввод невалидных значений настроек (T6.22) остаётся возможным | -- 2.47.2 From 47495fa5db48e597e9fb6d5a92118758fd0450b1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:26:06 +0400 Subject: [PATCH 23/37] =?UTF-8?q?feat(hub):=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20CSV-=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20admin?= =?UTF-8?q?/jira-sync=20=D0=B2=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8,=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/importer.py | 75 ++++++++++++++++++++++++++++++++++++++++ tests/test_importer.py | 52 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/jira_hub/importer.py create mode 100644 tests/test_importer.py diff --git a/src/jira_hub/importer.py b/src/jira_hub/importer.py new file mode 100644 index 0000000..f0ce8f6 --- /dev/null +++ b/src/jira_hub/importer.py @@ -0,0 +1,75 @@ +"""Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор +(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует.""" +import csv +from pathlib import Path + +from . import mirror, settings + + +def _rows(path: Path) -> list[dict]: + if not path.exists(): + return [] + with open(path, encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def _clean(value) -> str | None: + value = (value or "").strip() + return value or None + + +def import_package(conn, folder) -> dict: + folder = Path(folder) + notes = 0 + csv_keys = [] + + for r in _rows(folder / "tasks.csv"): + key = _clean(r.get("Код")) + if not key: + continue + mirror.set_note(conn, key, { + "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")), + "owner_note": _clean(r.get("Комментарий актуализации"))}, "import") + notes += 1 + csv_keys.append(key) + + new_keys = {} # new:<строка> → ключ Jira (как в jira_sync.py) + for i, r in enumerate(_rows(folder / "new_tasks.csv"), 2): + real = _clean(r.get("Ключ")) + key = real or f"new:{i}" + if real: + new_keys[f"new:{i}"] = real + csv_keys.append(real) + mirror.set_note(conn, key, { + "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "rest_text": _clean(r.get("Основание (внутр.)")), + "owner_note": None if real else _clean(r.get("Тема"))}, "import") + notes += 1 + + for r in _rows(folder / "epics.csv"): + key = _clean(r.get("Ключ")) + if not key: + continue + mirror.set_note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}, "import") + notes += 1 + csv_keys.append(key) + + link_types = settings.get(conn, "link_types") or {} + expected, missing_links = [], [] + for r in _rows(folder / "links.csv"): + src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда"))) + dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда"))) + kind = (_clean(r.get("Тип")) or "").lower() + if not src or not dst or src.startswith("new:") or dst.startswith("new:"): + continue + link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)} + expected.append(link) + issue = mirror.get_issue(conn, src) + present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]) + if not present: + missing_links.append(link) + + known = set(mirror.known_keys(conn)) + missing_keys = sorted({k for k in csv_keys if k not in known}) + return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links} diff --git a/tests/test_importer.py b/tests/test_importer.py new file mode 100644 index 0000000..6f5e2db --- /dev/null +++ b/tests/test_importer.py @@ -0,0 +1,52 @@ +import csv + +from jira_hub import db, importer, mirror, settings + + +def write_csv(path, header, rows): + with open(path, "w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + w.writerow(header) + w.writerows(rows) + + +def make_package(folder): + write_csv(folder / "tasks.csv", + ["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации", + "Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"], + [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", "Актуализация 10.10.2026. Порядок", + "", "Прогнать на стенде", "Спринт 2"], + ["AURORA-1416", "Карточка", "UI", "В работе", "", "Сделан список", "Модалка", ""]]) + write_csv(folder / "new_tasks.csv", + ["Ключ", "Тема", "Вид работы", "Статус по факту", "Описание", "Основание (внутр.)", "Спринт"], + [["AURORA-1586", "Справочники: тестирование", "тестирование", "Ожидание разработки", "h3. Контекст", "решение 11.10", "Спринт 2"], + ["", "Ещё не создана", "разработка", "Ожидание разработки", "h3. Контекст", "идея", ""]]) + write_csv(folder / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"], + [["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"], ["NEW-1", "AURORA-1529", "Роли", "новый"]]) + write_csv(folder / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], + [["AURORA-1533", "блокирует начало", "AURORA-1416", "x"], + ["new:2", "связанные", "AURORA-1533", "y"], + ["new:3", "связанные", "AURORA-1533", "z"]]) + + +def test_import_notes_and_report(tmp_path): + make_package(tmp_path) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) + 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_Связанные"}]) + rep = importer.import_package(conn, tmp_path) + assert rep["notes"] == 6 + n = mirror.get_note(conn, "AURORA-1533") + assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование" + assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import" + assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список" + assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10" + assert mirror.get_note(conn, "new:3")["owner_note"] == "Ещё не создана" + assert mirror.get_note(conn, "AURORA-1529")["work_kind"] == "эпик" + assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"] + assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется + assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] + assert importer.import_package(conn, tmp_path)["notes"] == 6 + assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6 -- 2.47.2 From 621603952611c4ba12e847cf74672ec66c0e92fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:28:32 +0400 Subject: [PATCH 24/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index fcd107d..1d962ed 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -72,3 +72,4 @@ Non-goals — см. proposal.md. | 2026-10-11 | R-6: сбой резервной копии в `queue.claim` не блокирует выдачу команды: исключение ловится, пишется предупреждение в лог | копия защищает базу хаба и не является условием записи в Jira; иначе команда повисает в `claimed` на 10 минут | при сбое диска команда выполнится без свежей копии; строгий вариант — откат claim | | 2026-10-11 | R-7 (уточняет R-5): вложенный `db.transaction` использует `SAVEPOINT`; соединение не делится между потоками | внутренний блок, чья ошибка перехвачена вызывающим, не должен фиксировать частичные записи | лишние SAVEPOINT на каждый вложенный вызов — дёшево | | 2026-10-11 | R-8: T6.16 считается закрытой по формам тела; недостижимая ветка `except ValueError` в `h_settings_put` и широкое имя теста — minor, правятся в финальной волне | protected-ключи отсекаются предпроверкой, `settings.set_` значения не валидирует | введённое в заблуждение имя теста; ввод невалидных значений настроек (T6.22) остаётся возможным | +| 2026-10-11 | R-9: повторный импорт CSV передаёт в `set_note` только непустые значения; пустая ячейка не затирает существующее поле заметки | иначе повторный запуск `jira-hub import` молча стирает правки владельца, внесённые в хабе | поле заметки нельзя очистить повторным импортом (очистка — через `notes_set`) | -- 2.47.2 From acbc19c7eb4eba8e045cbcb383fe7fe28817e860 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:29:08 +0400 Subject: [PATCH 25/37] =?UTF-8?q?fix(hub):=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=85=D0=B0=D0=B1=D0=B0=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=BC?= =?UTF-8?q?=D0=B8=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B0=D0=BC=D0=B8=20CSV?= =?UTF-8?q?,=20=D0=B2=D0=B5=D1=81=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=B2=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пустая ячейка CSV не меняет существующее поле заметки; заметка без непустых полей не пишется, но учитывается в счётчике как раньше. Импорт обёрнут в db.transaction (set_note — SAVEPOINT). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/importer.py | 27 ++++++++++++++++------ tests/test_importer.py | 49 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/jira_hub/importer.py b/src/jira_hub/importer.py index f0ce8f6..6f269cb 100644 --- a/src/jira_hub/importer.py +++ b/src/jira_hub/importer.py @@ -1,9 +1,10 @@ """Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор -(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует.""" +(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует. +Пустая ячейка CSV не меняет существующее поле заметки (правки, сделанные в хабе, сохраняются).""" import csv from pathlib import Path -from . import mirror, settings +from . import db, mirror, settings def _rows(path: Path) -> list[dict]: @@ -18,8 +19,20 @@ def _clean(value) -> str | None: return value or None +def _note(conn, key, fields: dict) -> None: + """Записать только непустые поля; если их нет — заметку не трогаем.""" + fields = {k: v for k, v in fields.items() if v is not None} + if fields: + mirror.set_note(conn, key, fields, "import") + + def import_package(conn, folder) -> dict: folder = Path(folder) + with db.transaction(conn): + return _import(conn, folder) + + +def _import(conn, folder: Path) -> dict: notes = 0 csv_keys = [] @@ -27,10 +40,10 @@ def import_package(conn, folder) -> dict: key = _clean(r.get("Код")) if not key: continue - mirror.set_note(conn, key, { + _note(conn, key, { "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), "done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")), - "owner_note": _clean(r.get("Комментарий актуализации"))}, "import") + "owner_note": _clean(r.get("Комментарий актуализации"))}) notes += 1 csv_keys.append(key) @@ -41,17 +54,17 @@ def import_package(conn, folder) -> dict: if real: new_keys[f"new:{i}"] = real csv_keys.append(real) - mirror.set_note(conn, key, { + _note(conn, key, { "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), "rest_text": _clean(r.get("Основание (внутр.)")), - "owner_note": None if real else _clean(r.get("Тема"))}, "import") + "owner_note": None if real else _clean(r.get("Тема"))}) notes += 1 for r in _rows(folder / "epics.csv"): key = _clean(r.get("Ключ")) if not key: continue - mirror.set_note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}, "import") + _note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}) notes += 1 csv_keys.append(key) diff --git a/tests/test_importer.py b/tests/test_importer.py index 6f5e2db..7e77125 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -50,3 +50,52 @@ def test_import_notes_and_report(tmp_path): assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] assert importer.import_package(conn, tmp_path)["notes"] == 6 assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6 + + +def write_tasks(folder, comment): + write_csv(folder / "tasks.csv", + ["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации", + "Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"], + [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]]) + + +def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path): + write_tasks(tmp_path, "Актуализация 10.10.2026") + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + importer.import_package(conn, tmp_path) + mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub") + write_tasks(tmp_path, "") # пустая ячейка в CSV + importer.import_package(conn, tmp_path) + n = mirror.get_note(conn, "AURORA-1533") + assert n["owner_note"] == "правка в хабе" + assert n["rest_text"] == "Прогнать" + + +def test_reimport_updates_nonempty_cell(tmp_path): + write_tasks(tmp_path, "Первая версия") + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + importer.import_package(conn, tmp_path) + write_tasks(tmp_path, "Вторая версия") + importer.import_package(conn, tmp_path) + assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия" + + +def test_import_does_not_touch_issue_tables(tmp_path): + make_package(tmp_path) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}], + [{"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"] + before = (count("issue"), count("issue_link")) + importer.import_package(conn, tmp_path) + assert (count("issue"), count("issue_link")) == before + + +def test_missing_csv_files_give_empty_report(tmp_path): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + rep = importer.import_package(conn, tmp_path) + assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": []} -- 2.47.2 From cc0026bb1c8e1cd5372b3fcf9682e8893484abc8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:32:23 +0400 Subject: [PATCH 26/37] =?UTF-8?q?feat(hub):=20MCP-=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=20=E2=80=94=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=85=20HTTP=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- pyproject.toml | 2 +- src/jira_hub/mcp_server.py | 222 +++++++++++++++++++++++++++++++++++++ tests/test_mcp.py | 74 +++++++++++++ uv.lock | 113 +++++++++---------- 4 files changed, 352 insertions(+), 59 deletions(-) create mode 100644 src/jira_hub/mcp_server.py create mode 100644 tests/test_mcp.py diff --git a/pyproject.toml b/pyproject.toml index 1fdba5c..ac01160 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "jira-hub" version = "0.1.0" description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" requires-python = ">=3.13" -dependencies = ["mcp>=1.2"] +dependencies = ["mcp>=1.2,<2"] [project.scripts] jira-hub = "jira_hub.cli:main" diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py new file mode 100644 index 0000000..2a0aa98 --- /dev/null +++ b/src/jira_hub/mcp_server.py @@ -0,0 +1,222 @@ +"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). +Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" +import functools +import json +import time + +from mcp.server.fastmcp import FastMCP + +from .client import HubClient, HubError, HubUnavailable + +OFFLINE = "хаб не запущен: jira-hub serve" +POLL_SECONDS = 5 +AUTHOR = "claude" +mcp = FastMCP("jira-hub") + + +def _client() -> HubClient: + return HubClient.from_home() + + +def guard(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except HubUnavailable: + return OFFLINE + except HubError as e: + return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + return wrapper + + +# --- форматирование -------------------------------------------------------------- + +def _cut(text, n=80): + text = (text or "").replace("\n", " ") + return text if len(text) <= n else text[: n - 1] + "…" + + +def _v(value): + return "—" if value in (None, "", []) else str(value) + + +def fmt_issues(issues) -> str: + if not issues: + return "задач нет" + lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"] + for i in issues: + lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")), + _v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))])) + return "\n".join(lines) + + +def fmt_issue(d) -> str: + head = [f"{d['key']} — {d.get('summary')}", + f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " + f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}", + f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; " + f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"] + links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \ + [f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])] + note = d.get("note") or {} + notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] + cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] + return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"]) + + ["описание:", d.get("description") or "—"]) + + +def fmt_batch(b) -> str: + est = round((b.get("remaining_seconds") or 0) / 60) + lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; " + + ", ".join(f"{k}: {v}" for k, v in (b.get("counts") or {}).items())] + for c in b.get("commands", []): + lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}" + + (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else "")) + return "\n".join(lines) + + +def fmt_agent(a) -> str: + state = "онлайн" if a.get("online") else "офлайн" + cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" + out = [f"агент {state} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(a.get('last_seen_at'))}); {cur}; " + f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"] + if a.get("warning"): + out.append("внимание: " + a["warning"]) + for s in a.get("stuck", []): + out.append(f"нет ответа от агента: #{s['id']} {s['type']} с {s['claimed_at']}") + return "\n".join(out) + + +# --- инструменты ----------------------------------------------------------------- + +@mcp.tool() +@guard +def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", + label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str: + """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, + подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" + data = _client().get("/issues", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, + status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit) + return fmt_issues(data["issues"]) + + +@mcp.tool() +@guard +def issue(key: str) -> str: + """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" + return fmt_issue(_client().get(f"/issues/{key}")) + + +@mcp.tool() +@guard +def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, + rest_text: str | None = None, owner_note: str | None = None) -> str: + """Правка служебных заметок задачи (только переданные поля).""" + fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text, + rest_text=rest_text, owner_note=owner_note).items() if v is not None} + fields["updated_by"] = AUTHOR + note = _client().put(f"/issues/{key}/note", fields) + return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) + + +@mcp.tool() +@guard +def batch_create(title: str, commands: list[dict]) -> str: + """Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status| + ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам.""" + b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands}) + return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" + + +@mcp.tool() +@guard +def batch_status(id: int) -> str: + """Состояние пакета и его команд.""" + return fmt_batch(_client().get(f"/batches/{id}")) + + +@mcp.tool() +@guard +def batch_wait(id: int, timeout_minutes: int = 10) -> str: + """Ждать завершения пакета (до 60 мин), вернуть итог по командам.""" + deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 + while True: + b = _client().get(f"/batches/{id}") + if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: + return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)") + time.sleep(POLL_SECONDS) + + +@mcp.tool() +@guard +def batches(limit: int = 10) -> str: + """Последние пакеты со статусом.""" + items = _client().get("/batches", limit=limit)["batches"] + return "\n".join(fmt_batch(b) for b in items) or "пакетов нет" + + +@mcp.tool() +@guard +def command_retry(id: int) -> str: + """Повторить команду — новый пакет с теми же параметрами.""" + b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR}) + return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд" + + +@mcp.tool() +@guard +def batch_cancel(id: int) -> str: + """Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API).""" + return fmt_batch(_client().post(f"/batches/{id}/cancel", {})) + + +@mcp.tool() +@guard +def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = True) -> str: + """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). wait — дождаться результата.""" + body = {"full": True} if full or not keys else {"keys": keys} + body["author"] = AUTHOR + b = _client().post("/refresh", body) + return batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" + + +@mcp.tool() +@guard +def agent_status() -> str: + """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа.""" + return fmt_agent(_client().get("/agent")) + + +@mcp.tool() +@guard +def sync_log(limit: int = 10) -> str: + """Журнал обновлений зеркала.""" + runs = _client().get("/sync-runs", limit=limit)["runs"] + return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, " + f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}" + for r in runs) or "обновлений не было" + + +@mcp.tool() +@guard +def settings_get(key: str = "") -> str: + """Настройки хаба (все или одна по ключу).""" + data = _client().get("/settings") + return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1) + + +@mcp.tool() +@guard +def settings_set(key: str, value: str) -> str: + """Записать настройку: value — JSON. Поднимает settings_version.""" + try: + parsed = json.loads(value) + except ValueError as e: + return f"значение не JSON: {e}" + out = _client().put("/settings", {key: parsed}) + return f"settings_version = {out['settings_version']}" + + +def run() -> None: + mcp.run(transport="stdio") diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..9f8c494 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,74 @@ +import json +import threading + +import pytest + +from jira_hub import api, mcp_server, settings +from jira_hub.client import HubClient + + +@pytest.fixture +def live(tmp_path, monkeypatch): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_port}" + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) + monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) + yield {"agent": HubClient(base, tok["agent"])} + srv.shutdown() + + +def test_offline_message(monkeypatch): + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert mcp_server.issues() == mcp_server.OFFLINE + assert mcp_server.agent_status() == mcp_server.OFFLINE + + +def test_tools_roundtrip(live): + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23", + "assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []}) + out = mcp_server.issues(sprint="Спринт 2") + assert "A-1" in out and "Тема раз" in out and "ivanov" in out + assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе") + detail = mcp_server.issue("A-1") + assert "fact_status: В работе" in detail and "Готово" in detail + created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) + batch_id = int(created.split("#")[1].split()[0]) + assert "2 команд" in created and "мин" in created + bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}]) + assert bad.startswith("ошибка хаба 422") + cmd = live["agent"].post("/agent/claim", {})["command"] + live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) + tail = live["agent"].post("/agent/claim", {})["command"] + live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) + waited = mcp_server.batch_wait(batch_id, timeout_minutes=1) + assert "done" in waited and "failed" in waited and "HTTP 400" in waited + assert f"#{batch_id}" in mcp_server.batches(limit=5) + assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) + assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) + r = mcp_server.refresh(full=True, wait=False) + assert "Обновление" in r or "#" in r + assert "онлайн" in mcp_server.agent_status() + assert "A-1" in mcp_server.sync_log(limit=3) or "keys" in mcp_server.sync_log(limit=3) + assert "120" in mcp_server.settings_get("pacing") + assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) + assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422") + + +def test_formatters(): + assert mcp_server.fmt_issues([]) == "задач нет" + text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, + "sprint": None, "fact_status": None}]) + assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text + assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, + "rate_limited_24h": 0, "stuck": [], "warning": None, "last_seen_at": None, + "settings_version": None}) + + +def test_settings_set_bad_json(live): + assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON") diff --git a/uv.lock b/uv.lock index 6c144bb..561895d 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cffi" version = "2.1.1" @@ -201,41 +210,40 @@ wheels = [ ] [[package]] -name = "httpcore2" -version = "2.13.1" +name = "httpcore" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "certifi" }, { name = "h11" }, - { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] -name = "httpx2" -version = "2.13.1" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform != 'emscripten'" }, - { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, - { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, { name = "idna" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] -name = "httpx2-jsfetch" -version = "1.0" +name = "httpx-sse" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] @@ -270,7 +278,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.2" }] +requires-dist = [{ name = "mcp", specifier = ">=1.2,<2" }] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8" }] @@ -304,15 +312,15 @@ wheels = [ [[package]] name = "mcp" -version = "2.3.0" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx2" }, + { name = "httpx" }, + { name = "httpx-sse" }, { name = "jsonschema" }, - { name = "mcp-types" }, - { name = "opentelemetry-api" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -322,34 +330,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, -] - -[[package]] -name = "mcp-types" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, -] - -[[package]] -name = "opentelemetry-api" -version = "1.45.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, ] [[package]] @@ -481,6 +464,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -520,6 +517,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32" @@ -664,15 +670,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, ] -[[package]] -name = "truststore" -version = "0.10.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" -- 2.47.2 From 29f6b307529432511295bc87268b1c52b5cfcfa2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:33:08 +0400 Subject: [PATCH 27/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20R-10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 1d962ed..447ea78 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -73,3 +73,4 @@ Non-goals — см. proposal.md. | 2026-10-11 | R-7 (уточняет R-5): вложенный `db.transaction` использует `SAVEPOINT`; соединение не делится между потоками | внутренний блок, чья ошибка перехвачена вызывающим, не должен фиксировать частичные записи | лишние SAVEPOINT на каждый вложенный вызов — дёшево | | 2026-10-11 | R-8: T6.16 считается закрытой по формам тела; недостижимая ветка `except ValueError` в `h_settings_put` и широкое имя теста — minor, правятся в финальной волне | protected-ключи отсекаются предпроверкой, `settings.set_` значения не валидирует | введённое в заблуждение имя теста; ввод невалидных значений настроек (T6.22) остаётся возможным | | 2026-10-11 | R-9: повторный импорт CSV передаёт в `set_note` только непустые значения; пустая ячейка не затирает существующее поле заметки | иначе повторный запуск `jira-hub import` молча стирает правки владельца, внесённые в хабе | поле заметки нельзя очистить повторным импортом (очистка — через `notes_set`) | +| 2026-10-11 | R-10: зависимость `mcp>=1.2,<2` (закреплена исполнителем Task 8) | `uv` ставит mcp 2.3.0, где `mcp.server.fastmcp` удалён (FastMCP стал MCPServer); код плана написан под FastMCP | позже нужна миграция на MCPServer; пока ограничение исключает неожиданный ломающий апгрейд | -- 2.47.2 From f3ac1eb5c27b25a355db615df4075e52ada1f6d3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:35:41 +0400 Subject: [PATCH 28/37] =?UTF-8?q?test(hub):=20MCP=20=E2=80=94=20=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=20=D0=B8=20=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=20batch=5Fwait,=20=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B0=D1=81=D1=81=D0=B5=D1=80=D1=82=D1=8B=20ref?= =?UTF-8?q?resh/sync=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _client() в batch_wait создаётся один раз перед циклом. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/mcp_server.py | 3 +- tests/test_mcp.py | 78 +++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 2a0aa98..b7ceb61 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -141,8 +141,9 @@ def batch_status(id: int) -> str: def batch_wait(id: int, timeout_minutes: int = 10) -> str: """Ждать завершения пакета (до 60 мин), вернуть итог по командам.""" deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 + client = _client() while True: - b = _client().get(f"/batches/{id}") + b = client.get(f"/batches/{id}") if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)") time.sleep(POLL_SECONDS) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 9f8c494..ab52dd8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,5 +1,7 @@ import json +import re import threading +from types import SimpleNamespace import pytest @@ -52,9 +54,8 @@ def test_tools_roundtrip(live): assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) r = mcp_server.refresh(full=True, wait=False) - assert "Обновление" in r or "#" in r + assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r) assert "онлайн" in mcp_server.agent_status() - assert "A-1" in mcp_server.sync_log(limit=3) or "keys" in mcp_server.sync_log(limit=3) assert "120" in mcp_server.settings_get("pacing") assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422") @@ -72,3 +73,76 @@ def test_formatters(): def test_settings_set_bad_json(live): assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON") + + +def _batch(status): + return {"id": 7, "title": "т", "author": "claude", "created_at": "2026-10-11", "status": status, + "remaining_seconds": 0, "counts": {status: 1}, "commands": []} + + +class FakeClient: + def __init__(self, statuses, runs=None): + self.statuses, self.calls, self.runs = list(statuses), 0, runs + + def get(self, path, **params): + if path == "/sync-runs": + return {"runs": self.runs} + self.calls += 1 + return _batch(self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0]) + + +def _fake_time(monkeypatch, step=1.0): + clock = {"now": 0.0, "sleeps": 0, "deadlines": []} + + def monotonic(): + clock["now"] += step + return clock["now"] + + def sleep(_): + clock["sleeps"] += 1 + + monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic, sleep=sleep)) + return clock + + +def test_batch_wait_polls_until_done(monkeypatch): + fake = FakeClient(["running", "running", "done"]) + monkeypatch.setattr(mcp_server, "_client", lambda: fake) + clock = _fake_time(monkeypatch) + out = mcp_server.batch_wait(7, timeout_minutes=10) + assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out + assert fake.calls == 3 and clock["sleeps"] == 2 + + +def test_batch_wait_timeout(monkeypatch): + fake = FakeClient(["running"]) + monkeypatch.setattr(mcp_server, "_client", lambda: fake) + clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов + out = mcp_server.batch_wait(7, timeout_minutes=1) + assert "таймаут ожидания" in out + assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 + + +@pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)]) +def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): + fake = FakeClient(["running"]) + monkeypatch.setattr(mcp_server, "_client", lambda: fake) + clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута + mcp_server.batch_wait(7, timeout_minutes=asked) + # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна + assert fake.calls == minutes + + +def test_client_created_once_per_wait(monkeypatch): + fake, made = FakeClient(["running", "running", "done"]), [] + monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake) + _fake_time(monkeypatch) + mcp_server.batch_wait(7) + assert len(made) == 1 + + +def test_sync_log_content(monkeypatch): + runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None, + "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] + monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) + assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1" -- 2.47.2 From d7f82cb611189e033776e28c2fc7e58e329218ce Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:47:24 +0400 Subject: [PATCH 29/37] =?UTF-8?q?feat(hub):=20CLI,=20README,=20=D0=BE?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- ARCHITECTURE.md | 32 +++++++- CLAUDE.md | 29 +++++--- README.md | 174 ++++++++++++++++++++++++++++++++++++++++++++ src/jira_hub/cli.py | 68 +++++++++++++++++ tests/test_cli.py | 25 +++++++ 5 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 README.md create mode 100644 src/jira_hub/cli.py create mode 100644 tests/test_cli.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a85988..d77b461 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,15 +1,39 @@ # Архитектура -{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}} +Хаб моста Jira: SQLite-зеркало задач AURORA/AWPCA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает. ## Модули | Модуль | Ответственность | Зависит от | |---|---|---| -| | | | +| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/` | stdlib | +| `db` | схема SQLite, миграции, `connect` | stdlib | +| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | +| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` | +| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | +| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | +| `backup` | копии файла базы, ротация | stdlib | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup` | +| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | +| `mcp_server` | инструменты MCP по stdio | `client` | +| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | +| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` | + +Цепочки: `api → queue → commands/mirror/settings → db`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. + +## Потоки данных +- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. +- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. +- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. ## Границы -- Что не должно импортировать что. +- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. +- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. +- `queue` не повторяет команды: нет автоматических ретраев, повтор — новый пакет. +- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`. +- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api` и `client`. ## Открытые вопросы -- +- Просмотрщик на живых данных и правки из UI — отдельные change'и (`viewer-live`, `viewer-edit`). +- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. +- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`). diff --git a/CLAUDE.md b/CLAUDE.md index 768e0b3..376ab86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,28 +1,35 @@ # jira-hub ## Что это -{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}} +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спека: `openspec/changes/bridge-core/`. ## Стек и ограничения -- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}} -- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}} +- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10). +- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. +- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. +- Хаб не повторяет команды сам: повтор — новая команда по запросу человека/интерфейса. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`. Порт по умолчанию 8790. +- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура -- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью. -- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно. -- {{Ключевые интерфейсы/порты между ними, если есть.}} +- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. +- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`) ← `cli`. +- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api`. +- Подробная карта модулей и границ — `ARCHITECTURE.md`. ## Команды -- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор. -- `{{npm run build / ...}}` — сборка. -- `{{npm run dev / ...}}` — локальный запуск. +- `uv run pytest` — весь набор тестов. Зелёным считается только весь набор. +- `uv sync` — установка окружения. +- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. +- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. +- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены. ## Тесты - Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. -- {{Где лежит тестовое окружение (vault, БД, песочница).}} +- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют. ## Первая подсистема -{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}} +Очередь команд (`queue.py`, Task 5): пакеты, порядок `(batch.created_at, seq)`, одна команда в полёте, временные ключи `new:`, подтверждение по состоянию зеркала. Остальное (API, MCP, импорт) надстраивается над ней. ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a124c2 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# jira-hub + +Хаб моста Jira для АРМ КА: зеркало задач проекта AURORA (метка AWPCA) в SQLite, служебные заметки, очередь команд, +HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только +агент на виртуалке (репозиторий `jira-agent`), пароль Jira хабу неизвестен. +Спека: `openspec/changes/bridge-core/` (proposal, design, specs). + +## Установка + +```bash +uv sync +``` + +Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): +`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. + +## Первый запуск + +```bash +uv run jira-hub init --config ../admin/jira-sync/config.example.json +uv run jira-hub serve # 0.0.0.0:8790 +``` + +`init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску, +приоритеты), и создаёт два токена: агентский и клиентский. Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`, +`ca_bundle`) остаются у агента и в хаб не попадают. Повторный `init` отказывает ("уже инициализирован"); с `--force` +настройки переписываются. Токены можно показать позже: `uv run jira-hub tokens`. + +Передать агенту нужно адрес хаба и агентский токен: через общую папку виртуалки положить в `agent.json` +`"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<агентский токен>"`. Клиентский токен остаётся на этой машине: +его использует MCP (читает из базы сам). + +Порт меняется ключом `--port` или настройкой `port`; адрес прослушивания — `--host`. + +## Импорт пакета admin/jira-sync + +```bash +uv run jira-hub import ../admin/jira-sync +``` + +Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON: +`notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и +`missing_links` (ожидаемые связи, которых нет). Запускать после первого `refresh` с `full: true`: пока зеркало пусто, +все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются. + +## MCP в Claude + +`jira-hub mcp` запускает MCP-сервер по stdio; он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты +отвечают «хаб не запущен: jira-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят), +блок для проекта `awp-ca`: + +```json +"jira-hub": {"type": "stdio", "command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/awp-ca/jira-hub", "jira-hub", "mcp"]} +``` + +Либо командой: `claude mcp add jira-hub -- uv run --project /Users/andrey.epifancev/projects/awp-ca/jira-hub jira-hub mcp`. + +| Инструмент | Параметры | Результат | +|---|---|---| +| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач | +| `issue` | `key` | поля, связи, заметки, последние команды | +| `notes_set` | `key`, поля заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту | +| `batches` | `limit` | последние пакеты | +| `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены | +| `refresh` | `keys` или `full`, `wait` | `sync_run` или id пакета | +| `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | +| `sync_log` | `limit` | последние загрузки состояния | +| `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | + +Автор команд из MCP: `claude`; из API без автора: `api`. + +## HTTP API + +Все маршруты требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422). + +| Маршрут | Токен | Назначение | +|---|---|---| +| `POST /agent/claim` | агент | взять следующую команду (долгий запрос до 30 с) | +| `GET /agent/settings` | агент | настройки для агента (без токенов) | +| `GET /agent/commands/{id}` | агент | команда по id | +| `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) | +| `POST /agent/state` | агент | загрузка состояния задач пачками | +| `GET /agent/known-keys` | агент | ключи зеркала | +| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | +| `GET /issues/{key}` | клиент | задача целиком | +| `PUT /issues/{key}/note` | клиент | заметки (частично) | +| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд | +| `POST /batches/{id}/cancel` | клиент | отмена пакета | +| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | +| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента, журнал загрузок | +| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | + +```bash +TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') +curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20" +``` + +## Контракт команд + +Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же +результатом завершается `done` без записи. + +| Тип | Параметры | +|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | +| `ensure_comment` | `key`, `marker`, `body` | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | +| `ensure_link` | `source`, `target`, `link_type`, `replace` | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | +| `refresh` | `keys` или `jql` | + +Ключ в параметре может быть временным `new:`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же +пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`): + +```json +{"title": "1538 в спринт 2", "author": "claude", "commands": [ + {"type": "ensure_fields", "params": {"key": "AURORA-1538", "duedate": "2026-10-23"}}, + {"type": "ensure_link", "params": {"source": "AURORA-1537", "target": "AURORA-1538", "link_type": "связанные", "replace": true}}, + {"type": "ensure_sprint", "params": {"key": "AURORA-1538", "sprint": "Спринт 2"}} +]} +``` + +Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30 с, `refresh` 2 с (настройка `pacing`). + +## Пример сценария + +1. `jira-hub serve` на Mac mini, агент запущен на виртуалке. +2. В Claude: `refresh` с `full` загружает задачи в зеркало; `jira-hub import` переносит заметки из CSV. +3. `batch_create` ставит пакет; `batch_wait` ждёт результата; `issue` показывает итог. + +## Эксплуатация + +- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` + и хранит последние `backup_keep` копий (по умолчанию 30). +- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала, + хаб ставит пакет «Автообновление». +- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`. +- Команда «нет ответа от агента»: команда в `claimed` без отчёта. Дождитесь агента (перезапущенный агент отчитается) или + отмените: `POST /commands/{id}/cancel` с `{"force": true}`. Хаб сам команды не повторяет; если факт уже виден после + `refresh`, команда становится `confirmed_by_state`. Повтор: `command_retry`. +- Автозапуск `serve` через launchd: `~/Library/LaunchAgents/ru.aepif.jira-hub.plist` + +```xml + + + + Labelru.aepif.jira-hub + ProgramArguments + uvrun--project + /Users/andrey.epifancev/projects/awp-ca/jira-hub + jira-hubserve + + EnvironmentVariables + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + RunAtLoad + KeepAlive + StandardErrorPath/tmp/jira-hub.err.log + +``` + +Загрузить: `launchctl load ~/Library/LaunchAgents/ru.aepif.jira-hub.plist`. + +## Тесты + +```bash +uv run pytest +``` diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py new file mode 100644 index 0000000..7a51962 --- /dev/null +++ b/src/jira_hub/cli.py @@ -0,0 +1,68 @@ +"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens.""" +import argparse +import json +import sys + +from . import db, importer, paths, settings + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб") + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены") + p.add_argument("--config", required=True) + p.add_argument("--force", action="store_true") + p = sub.add_parser("serve", help="запустить HTTP API") + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int) + p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") + p.add_argument("folder") + sub.add_parser("mcp", help="MCP-сервер по stdio") + sub.add_parser("tokens", help="показать токены агента и клиента") + args = ap.parse_args(argv) + + if args.cmd == "serve": + from . import api + hub = api.Hub(paths.db_path()) + conn = hub.conn() + port = args.port or int(settings.get(conn, "port") or 8790) + conn.close() + api.serve(hub, args.host, port) + return 0 + if args.cmd == "mcp": + from . import mcp_server + mcp_server.run() + return 0 + + conn = db.connect(paths.db_path()) + settings.ensure_defaults(conn) + try: + if args.cmd == "init": + with open(args.config, encoding="utf-8") as f: + cfg = json.load(f) + try: + keys = settings.init_from_config(conn, cfg, force=args.force) + except RuntimeError as e: + print(e, file=sys.stderr) + return 1 + print("перенесено: " + ", ".join(keys)) + _print_tokens(conn) + return 0 + if args.cmd == "import": + print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1)) + return 0 + if args.cmd == "tokens": + _print_tokens(conn) + return 0 + finally: + conn.close() + return 2 + + +def _print_tokens(conn) -> None: + tok = settings.tokens(conn) + print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d1733a9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,25 @@ +import json +from pathlib import Path + +from jira_hub import cli, db, paths, settings + +FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" + + +def test_init_import_tokens(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + assert cli.main(["init", "--config", str(FIXTURE)]) == 0 + out = capsys.readouterr().out + assert "transitions_map" in out and "агентский токен" in out + assert cli.main(["init", "--config", str(FIXTURE)]) == 1 + assert "уже инициализирован" in capsys.readouterr().err + conn = db.connect(paths.db_path()) + assert settings.get(conn, "board_id") == 4216 + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8") + assert cli.main(["import", str(tmp_path / "pkg")]) == 0 + rep = json.loads(capsys.readouterr().out) + assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"] + assert cli.main(["tokens"]) == 0 + assert settings.tokens(conn)["client"] in capsys.readouterr().out + conn.close() -- 2.47.2 From 181dd659589324b9fc958a47e15ec8631f021137 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:51:11 +0400 Subject: [PATCH 30/37] =?UTF-8?q?test(hub):=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D0=B8=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20HTTP=20=D1=81=20=D0=B8=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фикстура останавливает сервер (server_close, join потока), иначе ResourceWarning о незакрытом сокете. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- tests/test_e2e_hub.py | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_e2e_hub.py diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py new file mode 100644 index 0000000..3ac6ba9 --- /dev/null +++ b/tests/test_e2e_hub.py @@ -0,0 +1,59 @@ +import threading + +import pytest + +from jira_hub import api, settings +from jira_hub.client import HubClient + + +@pytest.fixture +def live(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + base = f"http://127.0.0.1:{srv.server_port}" + yield HubClient(base, tok["agent"]), HubClient(base, tok["client"]) + srv.shutdown() + srv.server_close() + th.join(5) + + +def test_create_link_sprint_flow(live): + agent, client = live + client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"}) + b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}}, + ]}) + assert b["count"] == 4 + seen = [] + while True: + cmd = agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] + if cmd is None: + break + seen.append((cmd["type"], cmd["params"])) + if cmd["type"] == "ensure_issue": + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"key": "A-90"}, "jira_calls": 2}) + elif cmd["type"] == "refresh": + agent.post("/agent/state", {"command_id": cmd["id"], "issues": [ + {"key": "A-90", "summary": "Новая", "duedate": "2026-10-23", "sprint": "Спринт 2", "labels": [], + "created": "2026-10-11T10:00:00.000+0300"}, + {"key": "A-1", "summary": "Старая", "labels": []}], + "links": [{"source_key": "A-90", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]}) + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) + else: + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) + assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"] + assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"] + view = client.get(f"/batches/{b['id']}") + assert view["status"] == "done" and all(c["status"] == "done" for c in view["commands"]) + issue = client.get("/issues/A-90") + assert issue["note"]["work_kind"] == "разработка" and issue["links_out"][0]["target_key"] == "A-1" + assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2 -- 2.47.2 From e221a18b465b5d31dd5355b57a4f94d9f2953ff4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:54:15 +0400 Subject: [PATCH 31/37] =?UTF-8?q?test(hub):=20=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=B6=D1=83=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B5=20=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D0=B5=D1=80=D1=82=D1=8B=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20(T10.1=E2=80=93T10.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверяются: одна команда в полёте (второй claim пуст), зеркало не пишется до /agent/state, заметка переезжает с new:60 на A-90, статус пакета running с remaining_seconds > 0 между командами и done с 0 в конце, связь проверяется по полям source_key/target_key/link_type. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- tests/test_e2e_hub.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 3ac6ba9..9782309 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -3,7 +3,7 @@ import threading import pytest from jira_hub import api, settings -from jira_hub.client import HubClient +from jira_hub.client import HubClient, HubError @pytest.fixture @@ -33,27 +33,47 @@ def test_create_link_sprint_flow(live): {"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}}, ]}) assert b["count"] == 4 + bid = b["id"] + assert client.get(f"/batches/{bid}")["status"] == "queued" seen = [] while True: cmd = agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] if cmd is None: break seen.append((cmd["type"], cmd["params"])) + view = client.get(f"/batches/{bid}") + assert view["status"] == "running" and view["remaining_seconds"] > 0 if cmd["type"] == "ensure_issue": + # одна команда в полёте: зависимая ensure_link не выдаётся до done ensure_issue + assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] is None agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"key": "A-90"}, "jira_calls": 2}) + # done ensure_issue не пишет в зеркало; заметка переехала с new:60 на A-90 + with pytest.raises(HubError) as e: + client.get("/issues/A-90") + assert e.value.status == 404 + with pytest.raises(HubError) as e: + client.get("/issues/new:60") + assert e.value.status == 404 + view = client.get(f"/batches/{bid}") + assert view["status"] == "running" and view["remaining_seconds"] > 0 elif cmd["type"] == "refresh": agent.post("/agent/state", {"command_id": cmd["id"], "issues": [ {"key": "A-90", "summary": "Новая", "duedate": "2026-10-23", "sprint": "Спринт 2", "labels": [], "created": "2026-10-11T10:00:00.000+0300"}, {"key": "A-1", "summary": "Старая", "labels": []}], "links": [{"source_key": "A-90", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]}) + assert client.get("/issues/A-90")["summary"] == "Новая" agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) else: agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"] assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"] - view = client.get(f"/batches/{b['id']}") - assert view["status"] == "done" and all(c["status"] == "done" for c in view["commands"]) + view = client.get(f"/batches/{bid}") + assert view["status"] == "done" and view["remaining_seconds"] == 0 + assert all(c["status"] == "done" for c in view["commands"]) issue = client.get("/issues/A-90") - assert issue["note"]["work_kind"] == "разработка" and issue["links_out"][0]["target_key"] == "A-1" + assert issue["note"]["work_kind"] == "разработка" + assert len(issue["links_out"]) == 1 + assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные" + assert client.get("/issues/A-1")["links_in"] == [{"source_key": "A-90", "link_type": "ASUPR_Связанные"}] assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2 -- 2.47.2 From 3c37e851e93ade4b9ef75d7ce417cd102f2f9ca4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:06:24 +0400 Subject: [PATCH 32/37] =?UTF-8?q?docs:=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20R-11=E2=80=A6R-13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 447ea78..1e42562 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -74,3 +74,6 @@ Non-goals — см. proposal.md. | 2026-10-11 | R-8: T6.16 считается закрытой по формам тела; недостижимая ветка `except ValueError` в `h_settings_put` и широкое имя теста — minor, правятся в финальной волне | protected-ключи отсекаются предпроверкой, `settings.set_` значения не валидирует | введённое в заблуждение имя теста; ввод невалидных значений настроек (T6.22) остаётся возможным | | 2026-10-11 | R-9: повторный импорт CSV передаёт в `set_note` только непустые значения; пустая ячейка не затирает существующее поле заметки | иначе повторный запуск `jira-hub import` молча стирает правки владельца, внесённые в хабе | поле заметки нельзя очистить повторным импортом (очистка — через `notes_set`) | | 2026-10-11 | R-10: зависимость `mcp>=1.2,<2` (закреплена исполнителем Task 8) | `uv` ставит mcp 2.3.0, где `mcp.server.fastmcp` удалён (FastMCP стал MCPServer); код плана написан под FastMCP | позже нужна миграция на MCPServer; пока ограничение исключает неожиданный ломающий апгрейд | +| 2026-10-11 | R-11 (заменяет R-3): `ensure_issue` не подтверждается по состоянию, пока автор («мы») неизвестен — `check_confirmed` возвращает `(False, None)` при `reporter is None`; настройка `reporter` не вводится | логин Jira в настройках хаба запрещён спекой data-model; без фильтра по автору подтверждение находило чужую задачу с той же темой (запись в чужую задачу Jira) | потерянный отчёт по `ensure_issue` не лечится сверкой, только восстановлением агента по журналу; follow-up: агент передаёт `jira_user` в `claim` | +| 2026-10-11 | R-12: инструменты ожидания MCP (`batch_wait`, `refresh`) — `async`, ожидание через `asyncio.sleep`, HTTP через `asyncio.to_thread`; `refresh` по умолчанию `wait=False` | синхронный `time.sleep` до 60 минут блокирует event loop FastMCP (отмена, ping) | сменилось умолчание `refresh` в MCP | +| 2026-10-11 | R-13: в выдаче команда, ждущая stale `ensure_issue` (claimed старше 10 минут), пропускается вместе с остатком своего пакета, выдача идёт к следующему пакету | иначе один потерянный отчёт останавливает всю очередь, выход только отменой человеком | пакет с зависшим определителем висит до подтверждения или отмены человеком | -- 2.47.2 From a09b7943024b3decdb054caba266409705a3a662 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:07:52 +0400 Subject: [PATCH 33/37] =?UTF-8?q?fix(queue):=20ensure=5Fissue=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8E=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20(R-11,=20I-1);=20reco?= =?UTF-8?q?ncile=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D0=BD=D1=83=20(M-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/commands.py | 15 ++++++++------- src/jira_hub/queue.py | 10 +++++++--- tests/test_commands.py | 14 +++++++++++--- tests/test_queue.py | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index 913f558..d48ac57 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -108,17 +108,18 @@ def estimate(types, pacing: dict) -> int: def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]: """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются. - reporter: если задан, ensure_issue подтверждается только задачей, заведённой этим пользователем.""" + reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого + автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой + задачей не доказательство, а запись в чужую задачу недопустима.""" ctype, p = cmd["type"], cmd["params"] if ctype in ("ensure_comment", "refresh"): return False, None if ctype == "ensure_issue": - sql = "SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days')" - args = [p["summary"]] - if reporter is not None: - sql += " AND reporter = ?" - args.append(reporter) - row = conn.execute(sql + " ORDER BY created DESC LIMIT 1", args).fetchone() + if reporter is None: + return False, None + row = conn.execute("SELECT key FROM issue WHERE summary = ? AND reporter = ? " + "AND substr(created, 1, 10) >= date('now', '-7 days') ORDER BY created DESC LIMIT 1", + (p["summary"], reporter)).fetchone() return (True, {"key": row["key"]}) if row else (False, None) issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index ed45ff7..2977e5c 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -206,7 +206,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= def reconcile(conn) -> list[int]: """Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state.""" link_types = settings.get(conn, "link_types") or {} - reporter = settings.get(conn, "reporter") + reporter = settings.get(conn, "reporter") # задел: сейчас не задаётся, ensure_issue по состоянию не подтверждается (R-11); + # позже сюда придёт jira_user от агента changed = [] for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall(): cmd = _cmd(row) @@ -214,8 +215,11 @@ def reconcile(conn) -> list[int]: if not ok: continue with db.transaction(conn): - conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? WHERE id = ?", - (db.now(), _dump(result) if result else None, cmd["id"])) + cur = conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? " + "WHERE id = ? AND status = 'claimed'", + (db.now(), _dump(result) if result else None, cmd["id"])) + if cur.rowcount == 0: # отчёт агента или отмена опередили сверку + continue if cmd["type"] == "ensure_issue" and result: mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) changed.append(cmd["id"]) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6518e5a..c42aa58 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -111,17 +111,25 @@ def _ensure_issue(summary): def test_check_confirmed_issue_by_summary(): conn = _mirror() conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") - ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT) + ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") assert ok and result == {"key": "A-1"} - ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT) + ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT, reporter="petrov") assert not ok +def test_ensure_issue_without_reporter_never_confirmed(): + """R-11: пока автор неизвестен, совпадение темы не подтверждает ensure_issue.""" + conn = _mirror() + 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, reporter=None) == (False, None) + + def test_ensure_issue_older_than_7_days_not_confirmed(): conn = _mirror() conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'") - ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT) + ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") assert ok is False diff --git a/tests/test_queue.py b/tests/test_queue.py index fe697d3..b092239 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -225,3 +225,43 @@ def test_reconcile_respects_reporter_setting(): mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], []) assert queue.reconcile(conn) == [c["id"]] assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"} + + +def _claimed_ensure_issue(conn, summary="Тема"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}]) + c = queue.claim(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + return c + + +def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): + """R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает.""" + conn = fresh() + c = _claimed_ensure_issue(conn) + mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") + created = db.utcnow().isoformat() + mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], []) + assert queue.reconcile(conn) == [] + assert queue.get_command(conn, c["id"])["status"] == "claimed" + 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): + """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" + conn = fresh() + settings.set_(conn, "reporter", "robot") + c = _claimed_ensure_issue(conn) + mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") + mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", + "created": db.utcnow().isoformat(), "labels": []}], []) + real = queue.C.check_confirmed + + def racing(*a, **kw): + out = real(*a, **kw) + queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки + return out + + monkeypatch.setattr(queue.C, "check_confirmed", racing) + assert queue.reconcile(conn) == [] + assert queue.get_command(conn, c["id"])["status"] == "cancelled" + assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся -- 2.47.2 From 5a5914226fd0e67e76f4b0e5a8d6bcf7b8993731 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:08:52 +0400 Subject: [PATCH 34/37] =?UTF-8?q?fix(mirror):=20=D1=84=D0=BB=D0=B0=D0=B3?= =?UTF-8?q?=20neighbor=20=D0=BD=D0=B5=20=D1=81=D0=B1=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D1=87=D0=BD=D1=8B=D0=BC=20refresh=20=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B8=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20(I-2);=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 3 ++- src/jira_hub/mirror.py | 13 +++++++++---- src/jira_hub/queue.py | 14 ++++++++++++++ tests/test_api.py | 33 +++++++++++++++++++++++++++++++++ tests/test_mirror.py | 9 +++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index a66b193..1898b7d 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -111,7 +111,8 @@ def h_agent_result(hub, conn, m, q, body): def h_agent_state(hub, conn, m, q, body): with db.transaction(conn): - out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or []) + out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [], + authoritative=queue.is_full_refresh(conn, body.get("command_id"))) if body.get("command_id"): queue.record_state(conn, body["command_id"], out["issues"], out["links"]) return 200, out diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index a8961ef..701e2f3 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -12,9 +12,12 @@ _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} -def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None) -> dict: +def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict: """Upsert задач; связи с source_key среди присланных задач заменяются присланными. - Связи, у которых источник не прислан, не трогаются до загрузки их источника.""" + Связи, у которых источник не прислан, не трогаются до загрузки их источника. + authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая + в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по + произвольной JQL): флаг только поднимается, никогда не снижается.""" fetched_at = fetched_at or db.now() neighbors = set(neighbor_keys) keys = [] @@ -25,9 +28,11 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None) -> dict: row["fetched_at"] = fetched_at row["neighbor"] = 1 if it["key"] in neighbors else 0 cols = list(row) + sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")] + sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)") conn.execute( f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " - "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols if c != "key"), + "ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets), [row[c] for c in cols]) keys.append(it["key"]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) @@ -95,7 +100,7 @@ def set_note(conn, key, fields: dict, updated_by: str) -> dict: unknown = set(fields) - set(NOTE_FIELDS) if unknown: raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}") - with db.transaction(conn): # чтение, слияние и запись — атомарно (соединение общее для потоков) + with db.transaction(conn): # чтение, слияние и запись — атомарно (каждый запрос и поток открывает своё соединение, R-2) cur = get_note(conn, key) or {"key": key} cur.update(fields) cur["updated_at"], cur["updated_by"] = db.now(), updated_by diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 2977e5c..9521e85 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -174,6 +174,15 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non # --- отчёты ----------------------------------------------------------------- +def is_full_refresh(conn, command_id) -> bool: + """Команда — refresh по базовой JQL (полный прогон): только он вправе сбрасывать флаг neighbor.""" + if not command_id: + return False + cmd = get_command(conn, command_id) + return bool(cmd and cmd["type"] == "refresh" and cmd["params"].get("jql") + and cmd["params"]["jql"] == settings.get(conn, "base_jql")) + + def record_state(conn, command_id, issues_count, links_count) -> None: conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?", (int(issues_count), int(links_count), command_id)) @@ -198,6 +207,11 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= if cmd["type"] == "refresh": conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) + if status == "done" and is_full_refresh(conn, command_id): + # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед + run = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (command_id,)).fetchone() + if run: + conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],)) if cmd["type"] == "refresh" and status == "done": reconcile(conn) return get_command(conn, command_id) diff --git a/tests/test_api.py b/tests/test_api.py index 00c55d7..055eb2c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -241,3 +241,36 @@ def test_settings_put_invalid_is_4xx_and_atomic(hub): c.put("/settings", {"pacing": {"refresh": 3}, "tokens": {}}) assert 400 <= e.value.status < 500 assert c.get("/settings")["settings_version"] == v0 + + +def _run_refresh(hub, body, issues, neighbor_keys=()): + """Поставить refresh, взять его агентом, загрузить состояние, отчитаться done.""" + hub["client"].post("/refresh", body) + cmd = hub["agent"].post("/agent/claim", {})["command"] + assert cmd["type"] == "refresh" + hub["agent"].post("/agent/state", {"command_id": cmd["id"], "issues": issues, "links": [], + "neighbor_keys": list(neighbor_keys)}) + hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"}) + + +def test_refresh_by_keys_keeps_neighbor_flag(hub): + """I-2(b): точечное обновление соседней задачи не опускает флаг.""" + _run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []}, + {"key": "B-1", "summary": "сосед", "labels": []}], neighbor_keys=["B-1"]) + assert hub["client"].get("/issues/B-1")["neighbor"] is True + _run_refresh(hub, {"keys": ["B-1"]}, [{"key": "B-1", "summary": "сосед 2", "labels": []}]) + issue = hub["client"].get("/issues/B-1") + assert issue["summary"] == "сосед 2" and issue["neighbor"] is True + + +def test_full_refresh_marks_unseen_issue_as_neighbor(hub): + """I-2(a): полный прогон по базовой JQL: не обновлённая им задача получает neighbor=1.""" + conn = hub["hub"].conn() + conn.execute("INSERT INTO issue(key, summary, labels, fetched_at) VALUES ('X-1', 'вышла из выборки', '[]', '2020-01-01T00:00:00+00:00')") + conn.close() + _run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []}]) + assert hub["client"].get("/issues/X-1")["neighbor"] is True + assert hub["client"].get("/issues/A-1")["neighbor"] is False + # частичный refresh (по ключам) других задач ничего не помечает + _run_refresh(hub, {"keys": ["A-1"]}, [{"key": "A-1", "summary": "т2", "labels": []}]) + assert hub["client"].get("/issues/A-1")["neighbor"] is False diff --git a/tests/test_mirror.py b/tests/test_mirror.py index d825f62..710b2f7 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -44,6 +44,15 @@ def test_apply_state_marks_neighbors(): assert mirror.known_keys(conn) == ["A-1", "B-9"] +def test_partial_load_never_lowers_neighbor(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"}) + mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys} + assert mirror.get_issue(conn, "B-9")["neighbor"] is True + mirror.apply_state(conn, [issue("B-9")], [], authoritative=True) # полный прогон: вошла в базовую + assert mirror.get_issue(conn, "B-9")["neighbor"] is False + + def test_note_survives_refresh_and_renames(): conn = db.connect(":memory:") mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") -- 2.47.2 From 0dba6b76724604322a159d91ff8f9fbb3a842a57 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:08:57 +0400 Subject: [PATCH 35/37] =?UTF-8?q?fix(queue):=20=D0=B7=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D1=88=D0=B8=D0=B9=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BF=D1=80=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=20=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0,=20=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=8C=20=D0=B8=D0=B4=D1=91=D1=82=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D1=88=D0=B5=20(R-13,=20I-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/queue.py | 16 +++++++++++++++- tests/test_queue.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 9521e85..6544caa 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -108,10 +108,21 @@ def _definer_status(conn, batch_id, temp_key): return r["status"] if r else "failed" +def _definer_stale(conn, batch_id, temp_key) -> bool: + """Определитель взят агентом и давно не отвечает (старше STALE_MINUTES).""" + cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat() + return conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type = 'ensure_issue' AND status = 'claimed' " + "AND claimed_at <= ? AND json_extract(params, '$.temp_key') = ?", + (batch_id, cutoff, temp_key)).fetchone() is not None + + def _next(conn) -> dict | None: 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() + blocked_batches = set() # пакеты, ждущие зависшего определителя (R-13) for row in rows: + if row["batch_id"] in blocked_batches: + continue cmd = _cmd(row) needed = C.temp_keys_used(cmd) if needed: @@ -119,7 +130,10 @@ def _next(conn) -> dict | None: unresolved = [k for k in needed if k not in mapping] pending = [k for k in unresolved if _definer_status(conn, cmd["batch_id"], k) not in TERMINAL] if pending: - return None # создание ещё не завершено — ждём + if all(_definer_stale(conn, cmd["batch_id"], k) for k in pending): + blocked_batches.add(cmd["batch_id"]) # определитель завис: остаток пакета ждёт человека, + continue # очередь идёт к следующему пакету + return None # создание ещё не завершено — ждём (одна в полёте) cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping) if cmd["type"] == "refresh": cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)] diff --git a/tests/test_queue.py b/tests/test_queue.py index b092239..b3b6399 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -265,3 +265,33 @@ def test_reconcile_does_not_overwrite_finished_command(monkeypatch): assert queue.reconcile(conn) == [] assert queue.get_command(conn, c["id"])["status"] == "cancelled" assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся + + +def _two_batches_with_dependent(conn): + a = queue.create_batch(conn, "A", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}, + {"type": "ensure_link", "params": {"source": "new:1", "target": "A-1", "link_type": "связанные"}}]) + first = queue.claim(conn) + assert first["type"] == "ensure_issue" + b = queue.create_batch(conn, "B", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) + return a, b, first + + +def test_stale_definer_does_not_block_other_batches(): + """R-13: зависший ensure_issue с зависимыми не держит очередь целиком.""" + conn = fresh() + a, b, first = _two_batches_with_dependent(conn) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (first["id"],)) + got = queue.claim(conn) + assert got is not None and got["batch_id"] == b["id"] and got["type"] == "refresh" + # остаток пакета A остаётся в очереди, не выдаётся и не пропускается + assert [c["status"] for c in queue.batch_view(conn, a["id"])["commands"]][1:] == ["queued", "queued"] + + +def test_fresh_definer_still_blocks_queue(): + conn = fresh() + a, b, first = _two_batches_with_dependent(conn) + assert queue.claim(conn) is None # одна в полёте + conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],)) + nxt = queue.claim(conn) + assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9" -- 2.47.2 From 4f055a80d013bfa679bf41918ae0926d183858f9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:09:45 +0400 Subject: [PATCH 36/37] =?UTF-8?q?fix(api):=20PUT=20/settings=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8,=20422=20(I-5);=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20except,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 16 ++++++++++------ src/jira_hub/settings.py | 33 +++++++++++++++++++++++++++++++++ tests/test_api.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 1898b7d..d8dbd89 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -223,16 +223,20 @@ def h_settings_get(hub, conn, m, q, body): def h_settings_put(hub, conn, m, q, body): + """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения + (settings.validate): ошибка — 422, версия настроек не меняется.""" bad = [k for k in body if k in settings.PROTECTED] if bad: raise ApiError(422, "protected", ", ".join(bad)) + for key, value in body.items(): + try: + settings.validate(key, value) + except ValueError as e: + raise ApiError(422, "bad_setting", str(e)) version = settings.get(conn, "settings_version") - try: - with db.transaction(conn): - for key, value in body.items(): - version = settings.set_(conn, key, value) - except ValueError as e: # запрет на защищённые ключи из settings.set_ - raise ApiError(422, "protected", str(e)) + with db.transaction(conn): + for key, value in body.items(): + version = settings.set_(conn, key, value) return 200, {"settings_version": version} diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index 8093fe1..6a063c0 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -39,6 +39,39 @@ AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "f PROTECTED = {"settings_version", "schema_version", "tokens"} +def _is_int(v) -> bool: + return isinstance(v, int) and not isinstance(v, bool) + + +def validate(key, value) -> None: + """Проверить значение настройки до записи; ValueError — на 422 в API. + pacing — объект {тип команды: целое >= 0} только с известными типами команд; + backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535; + остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None; + неизвестные ключи и ключи с умолчанием None принимаются как есть.""" + if key == "pacing": + if not isinstance(value, dict): + raise ValueError("pacing — объект {тип команды: пауза в секундах}") + for ctype, pause in value.items(): + if ctype not in DEFAULT_PACING: + raise ValueError(f"pacing: неизвестный тип команды «{ctype}»") + if not _is_int(pause) or pause < 0: + raise ValueError(f"pacing.{ctype}: ожидается целое >= 0") + return + if key == "port": + if not _is_int(value) or not 1 <= value <= 65535: + raise ValueError("port — целое 1..65535") + return + if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"): + if not _is_int(value) or value < 0: + raise ValueError(f"{key} — целое >= 0") + return + default = DEFAULTS.get(key) + if default is not None and type(value) is not type(default): + names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"} + raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}") + + def ensure_defaults(conn) -> None: for key, value in DEFAULTS.items(): conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) diff --git a/tests/test_api.py b/tests/test_api.py index 055eb2c..62d3ec1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -234,7 +234,7 @@ def test_bad_body_shapes_are_422(hub, path, body): assert e.value.status == 422 -def test_settings_put_invalid_is_4xx_and_atomic(hub): +def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub): c = hub["client"] v0 = c.get("/settings")["settings_version"] with pytest.raises(HubError) as e: @@ -243,6 +243,33 @@ def test_settings_put_invalid_is_4xx_and_atomic(hub): assert c.get("/settings")["settings_version"] == v0 +@pytest.mark.parametrize("body", [ + {"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}}, + {"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1}, + {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5}, + {"labels": {}}, {"initialized": 1}, + {"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается +]) +def test_settings_put_invalid_value_is_422(hub, body): + c = hub["client"] + before = c.get("/settings") + with pytest.raises(HubError) as e: + c.put("/settings", body) + assert e.value.status == 422 + assert c.get("/settings") == before + + +def test_settings_put_valid_values_pass(hub): + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + out = c.put("/settings", {"pacing": {"refresh": 0, "ensure_fields": 90}, "backup_keep": 0, "port": 8800, + "auto_refresh_minutes": 15, "min_agent_version": 3, "link_types": {"x": "Y"}, + "labels": ["AWPCA"], "board_id": 12}) + assert out["settings_version"] == v0 + 8 + got = c.get("/settings") + assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 and got["board_id"] == 12 + + def _run_refresh(hub, body, issues, neighbor_keys=()): """Поставить refresh, взять его агентом, загрузить состояние, отчитаться done.""" hub["client"].post("/refresh", body) -- 2.47.2 From 1bc6ebe534192dff40f2b62e27fb4f8dff754a9d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:11:18 +0400 Subject: [PATCH 37/37] =?UTF-8?q?fix(mcp):=20batch=5Fwait=20=D0=B8=20refre?= =?UTF-8?q?sh=20=D0=B0=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5,=20refresh=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE?= =?UTF-8?q?=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20(R-12,=20I-3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- README.md | 4 +-- src/jira_hub/mcp_server.py | 37 +++++++++++++++++++------- tests/test_mcp.py | 54 ++++++++++++++++++++++++++++++++------ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6a124c2..2f92d70 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,10 @@ uv run jira-hub import ../admin/jira-sync | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка | -| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту | +| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту; ожидание асинхронное, сервер не блокируется | | `batches` | `limit` | последние пакеты | | `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены | -| `refresh` | `keys` или `full`, `wait` | `sync_run` или id пакета | +| `refresh` | `keys` или `full`, `wait` (по умолчанию `false`: только ставит пакет; `true` — ждёт до 30 мин) | id пакета или итог пакета | | `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | | `sync_log` | `limit` | последние загрузки состояния | | `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index b7ceb61..43106a0 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -1,6 +1,8 @@ """MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" +import asyncio import functools +import inspect import json import time @@ -18,7 +20,23 @@ def _client() -> HubClient: return HubClient.from_home() +def _hub_error(e: HubError) -> str: + return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + + def guard(fn): + """Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции.""" + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def awrapper(*args, **kwargs): + try: + return await fn(*args, **kwargs) + except HubUnavailable: + return OFFLINE + except HubError as e: + return _hub_error(e) + return awrapper + @functools.wraps(fn) def wrapper(*args, **kwargs): try: @@ -26,7 +44,7 @@ def guard(fn): except HubUnavailable: return OFFLINE except HubError as e: - return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + return _hub_error(e) return wrapper @@ -138,15 +156,15 @@ def batch_status(id: int) -> str: @mcp.tool() @guard -def batch_wait(id: int, timeout_minutes: int = 10) -> str: - """Ждать завершения пакета (до 60 мин), вернуть итог по командам.""" +async def batch_wait(id: int, timeout_minutes: int = 10) -> str: + """Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.""" deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 client = _client() while True: - b = client.get(f"/batches/{id}") + b = await asyncio.to_thread(client.get, f"/batches/{id}") if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)") - time.sleep(POLL_SECONDS) + await asyncio.sleep(POLL_SECONDS) @mcp.tool() @@ -174,12 +192,13 @@ def batch_cancel(id: int) -> str: @mcp.tool() @guard -def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = True) -> str: - """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). wait — дождаться результата.""" +async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: + """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; + wait=true — дождаться результата (до 30 мин).""" body = {"full": True} if full or not keys else {"keys": keys} body["author"] = AUTHOR - b = _client().post("/refresh", body) - return batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" + b = await asyncio.to_thread(_client().post, "/refresh", body) + return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" @mcp.tool() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ab52dd8..6ca2fc3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,3 +1,4 @@ +import asyncio import json import re import threading @@ -23,6 +24,7 @@ def live(tmp_path, monkeypatch): monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) yield {"agent": HubClient(base, tok["agent"])} srv.shutdown() + srv.server_close() def test_offline_message(monkeypatch): @@ -48,13 +50,14 @@ def test_tools_roundtrip(live): live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) tail = live["agent"].post("/agent/claim", {})["command"] live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) - waited = mcp_server.batch_wait(batch_id, timeout_minutes=1) + waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1)) assert "done" in waited and "failed" in waited and "HTTP 400" in waited assert f"#{batch_id}" in mcp_server.batches(limit=5) assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) - r = mcp_server.refresh(full=True, wait=False) + r = asyncio.run(mcp_server.refresh(full=True)) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r) + assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False))) assert "онлайн" in mcp_server.agent_status() assert "120" in mcp_server.settings_get("pacing") assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) @@ -98,10 +101,11 @@ def _fake_time(monkeypatch, step=1.0): clock["now"] += step return clock["now"] - def sleep(_): + async def sleep(_): clock["sleeps"] += 1 - monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic, sleep=sleep)) + monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic)) + monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread)) return clock @@ -109,7 +113,7 @@ def test_batch_wait_polls_until_done(monkeypatch): fake = FakeClient(["running", "running", "done"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch) - out = mcp_server.batch_wait(7, timeout_minutes=10) + out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10)) assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out assert fake.calls == 3 and clock["sleeps"] == 2 @@ -118,7 +122,7 @@ def test_batch_wait_timeout(monkeypatch): fake = FakeClient(["running"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов - out = mcp_server.batch_wait(7, timeout_minutes=1) + out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1)) assert "таймаут ожидания" in out assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 @@ -128,7 +132,7 @@ def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): fake = FakeClient(["running"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута - mcp_server.batch_wait(7, timeout_minutes=asked) + asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked)) # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна assert fake.calls == minutes @@ -137,7 +141,7 @@ def test_client_created_once_per_wait(monkeypatch): fake, made = FakeClient(["running", "running", "done"]), [] monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake) _fake_time(monkeypatch) - mcp_server.batch_wait(7) + asyncio.run(mcp_server.batch_wait(7)) assert len(made) == 1 @@ -146,3 +150,37 @@ def test_sync_log_content(monkeypatch): "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1" + + +def test_async_tools_are_coroutines_and_registered(): + assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh) + tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} + assert {"batch_wait", "refresh"} <= set(tools) + assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False + + +def test_async_guard_offline(monkeypatch): + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE + assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE + + +def test_batch_wait_does_not_block_event_loop(monkeypatch): + """Пока идёт ожидание, другая задача event loop продолжает выполняться.""" + fake = FakeClient(["running", "running", "done"]) + monkeypatch.setattr(mcp_server, "_client", lambda: fake) + monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01) + + async def scenario(): + ticks = [] + + async def ticker(): + for _ in range(5): + ticks.append(1) + await asyncio.sleep(0.005) + + out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker()) + return out, ticks + + out, ticks = asyncio.run(scenario()) + assert "done" in out and len(ticks) == 5 -- 2.47.2