136 KiB
Мост 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_fields120;ensure_comment,ensure_status,ensure_link,ensure_sprint30;refresh2. - Временные ключи
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
- Параллельные
claimот двух процессов агента (владелец случайно запустил второй): должна выдаться одна команда, вторая —null. Тест — Task 5 (test_claim_is_exclusive). - Отчёт агента по команде, которую человек отменил с
forceпока она былаclaimed: статус остаётсяcancelled, результат не затирается. Тест — Task 5 (test_report_after_force_cancel_keeps_cancelled). - Состояние от агента с ключами, которых нет в базовой JQL и которые не помечены как соседи, — не должны стать «нашими» молча;
neighborвыставляется по списку из запроса. Тест — Task 3 (test_apply_state_marks_neighbors). - Пакет из одного
refreshне получает хвостовогоrefreshи не делает резервную копию. Тест — Task 5 (test_refresh_only_batch_has_no_tail) и Task 6 (test_no_backup_for_refresh_batch). 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 (содержание, написать полностью по этим пунктам, на русском):
- Что это: хаб моста Jira (зеркало + очередь + API + MCP), ссылка на
openspec/changes/bridge-core/и на репозиторийjira-agent. - Установка:
uv sync; где лежат данные (~/.local/share/jira-hub/,JIRA_HUB_HOME). - Первый запуск:
uv run jira-hub init --config ../admin/jira-sync/config.example.json→ токены;uv run jira-hub serve(порт 8790, адрес Mac mini192.168.0.106); как передать агентский токен и адрес на виртуалку (через общую папку, вagent.json). - Импорт:
uv run jira-hub import ../admin/jira-sync, как читать отчёт (missing_keys,missing_links) после первогоrefresh full. - MCP: блок для
~/.claude.jsonпроектаawp-ca:
и список инструментов одной таблицей (из specs/mcp)."jira-hub": {"type": "stdio", "command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/awp-ca/jira-hub", "jira-hub", "mcp"]} - API: таблица маршрутов (из specs/hub-api) с указанием токена; пример
curl. - Контракт команд: таблица типов с параметрами (из specs/commands), пример пакета JSON.
- Эксплуатация: резервные копии (
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). - Тесты:
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:
awp-ca/CLAUDE.md, таблица репозиториев: строкиjira-hub/(aep/jira-hub — хаб моста Jira: зеркало, очередь, API, MCP) иjira-agent/(aep/jira-agent — агент на виртуалке).admin/jira-sync/README.md: в начале пометка «Заменено мостом jira-hub/jira-agent, CSV заморожены <дата первой сверки>», ссылка на оба репозитория;admin/README.md— строка про мост.~/.claude.json, проектawp-ca: MCPjira-hub(блок из README хаба).- Ввод в строй:
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→ из MCPrefresh full→jira-hub import admin/jira-sync→ сверитьmissing_keys/missing_links. - Артефакт просмотрщика на claude.ai не переопубликовывать (решение владельца).