# Мост 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 не переопубликовывать (решение владельца).