sdlc-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md
Andrey Epifancev 464f5ec62b plan: bridge-core — план реализации хаба (10 задач)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 21:35:21 +04:00

136 KiB
Raw Blame History

Мост 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 <token>; агентский токен — только /agent/*, клиентский — всё остальное; ошибки — JSON {error, detail}.
  • Зеркало (issue, issue_link) пишет только загрузка состояния от агента; issue_note обновление зеркала не трогает.
  • Хаб не повторяет команды сам; взятая команда без ответа видна как «нет ответа от агента с <claimed_at>».
  • Статусы команды: 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:<n> живут только в хабе; команде с временным ключом ставится depends_on на ensure_issue того же пакета.
  • Учётные данные Jira (auth, base_url, verify_ssl, ca_bundle) в настройках хаба отсутствуют.
  • Язык кода и комментариев — русский, идентификаторы — английские; коммиты — русские, с Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>.
  • Автор команд из 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:

[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:

"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP."""
__version__ = "0.1.0"

src/jira_hub/paths.py:

"""Где лежат данные хаба: каталог, база, резервные копии."""
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:

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:

"""Соединение с 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: Коммит
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 <noreply@anthropic.com>"

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:

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:

"""Настройки хаба: пары ключ → 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: Коммит
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 <noreply@anthropic.com>"

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:

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:

"""Зеркало задач 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: Коммит
git add src/jira_hub/mirror.py tests/test_mirror.py
git commit -m "feat(hub): зеркало задач, связи, служебные заметки

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

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:

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:<n>"),
    ([{"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:

"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
Здесь валидация, работа с ключами (в том числе временными new:<n>), оценка длительности и
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
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:<n>")
            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: Коммит
git add src/jira_hub/commands.py tests/test_commands.py
git commit -m "feat(hub): контракт команд — валидация, временные ключи, оценка, подтверждение по состоянию

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

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:

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:

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:

"""Резервная копия файла базы перед пакетом с записями в 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:

"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
Хаб никогда не повторяет команды сам (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: все зелёные

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 <noreply@anthropic.com>"

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:

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:

"""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:

"""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: Коммит
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 <noreply@anthropic.com>"

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:

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:

"""Разовый импорт 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: Коммит
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 <noreply@anthropic.com>"

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:

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:

"""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: Коммит
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 <noreply@anthropic.com>"

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:

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:

"""Команды 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:
    "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: Коммит
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 <noreply@anthropic.com>"

Task 10: Сквозной прогон хаба с имитацией агента

Files:

  • Test: tests/test_e2e_hub.py

Цель: один тест проходит весь путь «пакет с созданием задачи → выдача → подстановка ключа → состояние → подтверждение» через HTTP, как это сделает настоящий агент.

  • Step 1: Тест

tests/test_e2e_hub.py:

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: Коммит
git add tests/test_e2e_hub.py
git commit -m "test(hub): сквозной прогон очереди через HTTP с имитацией агента

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

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