From 464f5ec62b1e70b5ef2c9f2a763bb0edadfe15a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 21:35:21 +0400 Subject: [PATCH] =?UTF-8?q?plan:=20bridge-core=20=E2=80=94=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=85=D0=B0=D0=B1=D0=B0=20(10=20=D0=B7=D0=B0?= =?UTF-8?q?=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 не переопубликовывать (решение владельца).