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

2928 lines
136 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Мост Jira — хаб (jira-hub) — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Хаб на Mac mini: SQLite-зеркало задач Jira, очередь команд-«желаемых результатов», HTTP API для агента и интерфейсов, MCP для сессии Claude, разовый импорт CSV-пакета.
**Architecture:** Один процесс `jira-hub serve`: `http.server` (ThreadingHTTPServer) поверх модулей `db` → `settings` / `mirror` / `commands` / `queue` → `api`; каждый запрос открывает своё соединение SQLite (WAL, busy_timeout). `jira-hub mcp` — FastMCP по stdio, ходит в тот же HTTP API клиентским токеном. Хаб никогда не повторяет команды, не знает пароль Jira и не пишет в зеркало иначе как по состоянию от агента.
**Tech Stack:** Python 3.13, uv, stdlib `sqlite3` + `http.server`, пакет `mcp` (FastMCP), pytest.
**Spec:** `openspec/changes/bridge-core/` (proposal.md, design.md, specs/{data-model,commands,agent,hub-api,mcp,migration}/spec.md). План агента — в репозитории `jira-agent`: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
## Global Constraints
- Python `>=3.13`, зависимости только `mcp` (runtime) и `pytest` (dev); HTTP — stdlib, без FastAPI.
- База — один файл SQLite в `~/.local/share/jira-hub/hub.sqlite` (переопределение `JIRA_HUB_HOME`), WAL, `timeout=10`.
- Все маршруты под `Authorization: Bearer <token>`; агентский токен — только `/agent/*`, клиентский — всё остальное; ошибки — JSON `{error, detail}`.
- Зеркало (`issue`, `issue_link`) пишет только загрузка состояния от агента; `issue_note` обновление зеркала не трогает.
- Хаб не повторяет команды сам; взятая команда без ответа видна как «нет ответа от агента с <claimed_at>».
- Статусы команды: `queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`; статус пакета вычисляется.
- Паузы по умолчанию (`pacing`): `ensure_issue`, `ensure_fields` 120; `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30; `refresh` 2.
- Временные ключи `new:<n>` живут только в хабе; команде с временным ключом ставится `depends_on` на `ensure_issue` того же пакета.
- Учётные данные Jira (`auth`, `base_url`, `verify_ssl`, `ca_bundle`) в настройках хаба отсутствуют.
- Язык кода и комментариев — русский, идентификаторы — английские; коммиты — русские, с `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
- Автор команд из MCP — `claude`, из API без автора — `api`, автообновления — `hub`.
## Review Focus
1. Параллельные `claim` от двух процессов агента (владелец случайно запустил второй): должна выдаться одна команда, вторая — `null`. Тест — Task 5 (`test_claim_is_exclusive`).
2. Отчёт агента по команде, которую человек отменил с `force` пока она была `claimed`: статус остаётся `cancelled`, результат не затирается. Тест — Task 5 (`test_report_after_force_cancel_keeps_cancelled`).
3. Состояние от агента с ключами, которых нет в базовой JQL и которые не помечены как соседи, — не должны стать «нашими» молча; `neighbor` выставляется по списку из запроса. Тест — Task 3 (`test_apply_state_marks_neighbors`).
4. Пакет из одного `refresh` не получает хвостового `refresh` и не делает резервную копию. Тест — Task 5 (`test_refresh_only_batch_has_no_tail`) и Task 6 (`test_no_backup_for_refresh_batch`).
5. `PUT /settings` с ключом `tokens` или `settings_version` — 422, версия не меняется. Тест — Task 7 (`test_settings_put_rejects_protected`).
## Структура файлов
| Файл | Ответственность |
|---|---|
| `pyproject.toml` | пакет `jira_hub`, скрипт `jira-hub`, зависимости |
| `src/jira_hub/paths.py` | каталог данных, путь к базе, каталог копий |
| `src/jira_hub/db.py` | соединение, схема, миграция, транзакция |
| `src/jira_hub/settings.py` | настройки, токены, `init` из `config.example.json`, вид для агента |
| `src/jira_hub/mirror.py` | зеркало: загрузка состояния, заметки, выборки |
| `src/jira_hub/commands.py` | контракт команд: валидация, ключи, подстановка, оценка, подтверждение по состоянию |
| `src/jira_hub/queue.py` | пакеты, выдача, отчёты, отмена, повтор, сверка, автообновление |
| `src/jira_hub/backup.py` | резервные копии базы |
| `src/jira_hub/api.py` | HTTP: маршруты, токены, долгий `claim`, фоновое автообновление |
| `src/jira_hub/importer.py` | импорт CSV-пакета `admin/jira-sync` |
| `src/jira_hub/client.py` | HTTP-клиент хаба (для MCP и тестов) |
| `src/jira_hub/mcp_server.py` | инструменты MCP |
| `src/jira_hub/cli.py` | `init`, `serve`, `import`, `mcp`, `tokens` |
| `tests/` | pytest; `tests/fixtures/config.example.json` — копия из `admin/jira-sync` |
---
### Task 1: Скелет пакета и схема базы
**Files:**
- Create: `pyproject.toml`, `src/jira_hub/__init__.py`, `src/jira_hub/paths.py`, `src/jira_hub/db.py`
- Test: `tests/test_db.py`
**Interfaces:**
- Produces: `paths.home() -> Path`, `paths.db_path() -> Path`, `paths.backups_dir() -> Path`;
`db.connect(path) -> sqlite3.Connection` (row_factory=Row, autocommit, схема применена),
`db.transaction(conn)` — контекстный менеджер `BEGIN IMMEDIATE … COMMIT/ROLLBACK`, `db.now() -> str` (ISO UTC, секунды).
- [ ] **Step 1: pyproject и пакет**
`pyproject.toml`:
```toml
[project]
name = "jira-hub"
version = "0.1.0"
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
requires-python = ">=3.13"
dependencies = ["mcp>=1.2"]
[project.scripts]
jira-hub = "jira_hub.cli:main"
[dependency-groups]
dev = ["pytest>=8"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/jira_hub"]
[tool.pytest.ini_options]
testpaths = ["tests"]
```
`src/jira_hub/__init__.py`:
```python
"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP."""
__version__ = "0.1.0"
```
`src/jira_hub/paths.py`:
```python
"""Где лежат данные хаба: каталог, база, резервные копии."""
import os
from pathlib import Path
def home() -> Path:
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub")))
def db_path() -> Path:
return home() / "hub.sqlite"
def backups_dir() -> Path:
return home() / "backups"
```
Выполнить: `uv sync` (создаст `.venv` и `uv.lock`). Добавить в `.gitignore` строки `.venv/`, `__pycache__/`, `*.sqlite*`.
- [ ] **Step 2: Тест схемы**
`tests/test_db.py`:
```python
from jira_hub import db
def test_connect_creates_schema():
conn = db.connect(":memory:")
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "1"
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
def test_connect_is_idempotent(tmp_path):
path = tmp_path / "hub.sqlite"
db.connect(path).close()
conn = db.connect(path)
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
def test_transaction_rolls_back_on_error(tmp_path):
conn = db.connect(tmp_path / "hub.sqlite")
try:
with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
raise RuntimeError("boom")
except RuntimeError:
pass
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
```
- [ ] **Step 3: Запустить — убедиться, что падает**
Run: `uv run pytest tests/test_db.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.db'`
- [ ] **Step 4: Реализация db.py**
`src/jira_hub/db.py`:
```python
"""Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version."""
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
SCHEMA_VERSION = 1
SCHEMA = """
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issue (
key TEXT PRIMARY KEY,
id TEXT,
issue_type TEXT,
summary TEXT,
description TEXT,
status TEXT,
priority TEXT,
assignee TEXT,
reporter TEXT,
duedate TEXT,
planned_start TEXT,
planned_end TEXT,
epic_key TEXT,
labels TEXT NOT NULL DEFAULT '[]',
sprint TEXT,
created TEXT,
jira_updated TEXT,
fetched_at TEXT NOT NULL,
neighbor INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issue_link (
source_key TEXT NOT NULL,
target_key TEXT NOT NULL,
link_type TEXT NOT NULL,
PRIMARY KEY (source_key, target_key, link_type)
);
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key);
CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY,
fact_status TEXT,
work_kind TEXT,
done_text TEXT,
rest_text TEXT,
owner_note TEXT,
updated_at TEXT,
updated_by TEXT
);
CREATE TABLE IF NOT EXISTS batch (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
created_at TEXT NOT NULL,
cancelled_at TEXT,
estimate_seconds INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS command (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL REFERENCES batch(id),
seq INTEGER NOT NULL,
type TEXT NOT NULL,
params TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
claimed_at TEXT,
finished_at TEXT,
result TEXT,
error TEXT,
jira_calls INTEGER NOT NULL DEFAULT 0,
rate_limited INTEGER NOT NULL DEFAULT 0,
pause_seconds INTEGER,
depends_on INTEGER REFERENCES command(id)
);
CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq);
CREATE TABLE IF NOT EXISTS sync_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
kind TEXT NOT NULL,
query TEXT,
issues_count INTEGER NOT NULL DEFAULT 0,
links_count INTEGER NOT NULL DEFAULT 0,
jira_calls INTEGER NOT NULL DEFAULT 0,
rate_limited INTEGER NOT NULL DEFAULT 0,
command_id INTEGER
);
CREATE TABLE IF NOT EXISTS agent_status (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_seen_at TEXT,
agent_version TEXT,
settings_version INTEGER,
current_command_id INTEGER,
last_jira_call_at TEXT
);
"""
def now() -> str:
"""Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе."""
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def connect(path) -> sqlite3.Connection:
"""Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных
операций — transaction()."""
is_memory = str(path) == ":memory:"
if not is_memory:
Path(path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
if not is_memory:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
migrate(conn)
return conn
def migrate(conn: sqlite3.Connection) -> None:
conn.executescript(SCHEMA)
row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()
if row is None:
conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
@contextmanager
def transaction(conn: sqlite3.Connection):
"""BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт)."""
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
except BaseException:
conn.execute("ROLLBACK")
raise
else:
conn.execute("COMMIT")
```
- [ ] **Step 5: Запустить — зелёный**
Run: `uv run pytest tests/test_db.py -v`
Expected: 3 passed
- [ ] **Step 6: Коммит**
```bash
git add pyproject.toml uv.lock .gitignore src/jira_hub tests/test_db.py
git commit -m "feat(hub): скелет пакета, схема SQLite, транзакции
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import json
from pathlib import Path
import pytest
from jira_hub import db, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def fresh():
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
return conn
def test_defaults_and_tokens():
conn = fresh()
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA"
assert settings.get(conn, "pacing")["ensure_fields"] == 120
tok = settings.tokens(conn)
assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"]
assert "tokens" not in settings.get_all(conn)
def test_set_bumps_version():
conn = fresh()
v0 = settings.get(conn, "settings_version")
v1 = settings.set_(conn, "pacing", {"ensure_fields": 5})
assert v1 == v0 + 1
assert settings.get(conn, "pacing") == {"ensure_fields": 5}
@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"])
def test_protected_keys(key):
conn = fresh()
with pytest.raises(ValueError):
settings.set_(conn, key, "x")
def test_init_from_config():
conn = fresh()
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
keys = settings.init_from_config(conn, cfg)
assert "transitions_map" in keys
assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802"
assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"}
assert settings.get(conn, "board_id") == 4216
assert settings.get(conn, "pacing") == settings.DEFAULT_PACING
agent_view = settings.for_agent(conn)
assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view
assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные"
with pytest.raises(RuntimeError):
settings.init_from_config(conn, cfg)
settings.init_from_config(conn, cfg, force=True)
```
- [ ] **Step 2: Запустить — падает**
Run: `mkdir -p tests/fixtures && cp ../../../admin/jira-sync/config.example.json tests/fixtures/ && uv run pytest tests/test_settings.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.settings'`
- [ ] **Step 3: Реализация**
`src/jira_hub/settings.py`:
```python
"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version,
агент перечитывает настройки, когда видит новую версию в ответе claim."""
import json
import secrets
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
"ensure_link": 30, "ensure_sprint": 30, "refresh": 2}
DEFAULTS = {
"base_jql": "project = AURORA AND labels = AWPCA",
"project_key": "AURORA",
"pacing": DEFAULT_PACING,
"backup_keep": 30,
"auto_refresh_minutes": 60,
"min_agent_version": 1,
"port": 8790,
"settings_version": 1,
"initialized": False,
"issue_types": {},
"epic_name_field_id": None,
"field_ids": {},
"epic_link": {},
"priority_map": {},
"priority_value_key": "name",
"labels": [],
"status_labels": {},
"kind_labels": {},
"comment_marker": "",
"transitions_map": {},
"link_types": {},
"board_id": None,
}
# Что получает агент (без токенов и служебных ключей хаба)
AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
"priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker",
"transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"]
PROTECTED = {"settings_version", "schema_version", "tokens"}
def ensure_defaults(conn) -> None:
for key, value in DEFAULTS.items():
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value)))
if get(conn, "tokens") is None:
conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)",
(_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),))
def _dump(value) -> str:
return json.dumps(value, ensure_ascii=False)
def get(conn, key, default=None):
row = conn.execute("SELECT value FROM setting WHERE key = ?", (key,)).fetchone()
return default if row is None else json.loads(row["value"])
def get_all(conn, hide_tokens=True) -> dict:
out = {r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM setting ORDER BY key")}
if hide_tokens:
out.pop("tokens", None)
return out
def set_(conn, key, value, bump=True) -> int:
"""Записать настройку; bump=True поднимает settings_version. Защищённые ключи снаружи не меняются."""
if key in PROTECTED:
raise ValueError(f"настройка «{key}» не редактируется")
conn.execute("INSERT INTO setting(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, _dump(value)))
if bump:
version = get(conn, "settings_version", 1) + 1
conn.execute("UPDATE setting SET value = ? WHERE key = 'settings_version'", (_dump(version),))
return version
return get(conn, "settings_version", 1)
def for_agent(conn) -> dict:
return {key: get(conn, key) for key in AGENT_KEYS}
def tokens(conn) -> dict:
return get(conn, "tokens")
def pacing(conn) -> dict:
return {**DEFAULT_PACING, **(get(conn, "pacing") or {})}
def pacing_from_config(p: dict) -> dict:
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
"ensure_link": comment, "ensure_sprint": comment, "refresh": 2}
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
if get(conn, "initialized") and not force:
raise RuntimeError("хаб уже инициализирован; повторный init — с --force")
project = cfg["project_key"]
values = {
"project_key": project,
"base_jql": f"project = {project} AND labels = AWPCA",
"issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")},
"epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}},
"epic_name_field_id": cfg.get("epic_name_field_id"),
"field_ids": cfg.get("fields", {}),
"epic_link": cfg.get("epic_link", {}),
"priority_map": cfg.get("priority_map", {}),
"priority_value_key": cfg.get("priority_value_key", "name"),
"labels": cfg.get("labels", []),
"status_labels": cfg.get("status_labels", {}),
"kind_labels": cfg.get("kind_labels", {}),
"comment_marker": cfg.get("comment_marker", ""),
"transitions_map": cfg.get("transitions", {}).get("map", {}),
"link_types": cfg.get("link_types", {}),
"board_id": cfg.get("sprints", {}).get("board_id"),
"pacing": pacing_from_config(cfg.get("pacing", {})),
}
for key, value in values.items():
set_(conn, key, value, bump=False)
set_(conn, "initialized", True)
return sorted(values)
```
- [ ] **Step 4: Запустить — зелёный**
Run: `uv run pytest tests/test_settings.py -v`
Expected: 6 passed
- [ ] **Step 5: Коммит**
```bash
git add src/jira_hub/settings.py tests/test_settings.py tests/fixtures/config.example.json
git commit -m "feat(hub): настройки, токены, init из config.example.json
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import pytest
from jira_hub import db, mirror
def issue(key, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
"duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350",
"labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300",
"jira_updated": "2026-10-11T12:00:00.000+0300"}
base.update(over)
return base
def test_apply_state_upserts_and_replaces_links():
conn = db.connect(":memory:")
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
assert out == {"issues": 2, "links": 1}
mirror.apply_state(conn, [issue("A-1", summary="Новая тема")],
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1")
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}]
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
def test_links_from_other_source_survive():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
def test_apply_state_marks_neighbors():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
assert mirror.known_keys(conn) == ["A-1", "B-9"]
def test_note_survives_refresh_and_renames():
conn = db.connect(":memory:")
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
mirror.rename_note(conn, "new:7", "A-5")
mirror.apply_state(conn, [issue("A-5")], [])
got = mirror.get_issue(conn, "A-5")
assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import"
assert mirror.get_note(conn, "new:7") is None
with pytest.raises(ValueError):
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
def test_list_issues_filters():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
issue("A-3", sprint="Спринт 2", duedate=None)], [])
mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x")
keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})]
assert keys == ["A-1", "A-3"] # без срока — последней
assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"]
assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"]
assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"]
assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None
assert len(mirror.list_issues(conn, None, limit=1)) == 1
```
- [ ] **Step 2: Запустить — падает**
Run: `uv run pytest tests/test_mirror.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.mirror'`
- [ ] **Step 3: Реализация**
`src/jira_hub/mirror.py`:
```python
"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
from . import db
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"]
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"}
def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None) -> dict:
"""Upsert задач; связи с source_key среди присланных задач заменяются присланными.
Связи, у которых источник не прислан, не трогаются до загрузки их источника."""
fetched_at = fetched_at or db.now()
neighbors = set(neighbor_keys)
keys = []
with db.transaction(conn):
for it in issues:
row = {c: it.get(c) for c in ISSUE_COLUMNS}
row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False)
row["fetched_at"] = fetched_at
row["neighbor"] = 1 if it["key"] in neighbors else 0
cols = list(row)
conn.execute(
f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols if c != "key"),
[row[c] for c in cols])
keys.append(it["key"])
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
(link["source_key"], link["target_key"], link["link_type"]))
return {"issues": len(keys), "links": len(links)}
def known_keys(conn) -> list[str]:
return [r["key"] for r in conn.execute("SELECT key FROM issue ORDER BY key")]
def _issue_dict(row) -> dict:
d = dict(row)
d["labels"] = json.loads(d.get("labels") or "[]")
d["neighbor"] = bool(d.get("neighbor"))
return d
def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]:
filters = filters or {}
where, args = [], []
for name, sql in _FILTERS.items():
if filters.get(name) not in (None, ""):
where.append(sql)
args.append(filters[name])
if filters.get("label"):
where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)")
args.append(filters["label"])
if filters.get("neighbor") not in (None, ""):
where.append("i.neighbor = ?")
args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0)
if filters.get("q"):
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
+ (" WHERE " + " AND ".join(where) if where else "")
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])]
def get_issue(conn, key) -> dict | None:
row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone()
if row is None:
return None
d = _issue_dict(row)
d["links_out"] = [dict(r) for r in conn.execute(
"SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))]
d["links_in"] = [dict(r) for r in conn.execute(
"SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))]
d["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute(
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",
(f'%"{key}"%',))]
return d
def get_note(conn, key) -> dict | None:
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
return dict(row) if row else None
def set_note(conn, key, fields: dict, updated_by: str) -> dict:
unknown = set(fields) - set(NOTE_FIELDS)
if unknown:
raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}")
cur = get_note(conn, key) or {"key": key}
cur.update(fields)
cur["updated_at"], cur["updated_by"] = db.now(), updated_by
cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"]
conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]),
[cur.get(c) for c in cols])
return get_note(conn, key)
def rename_note(conn, old_key, new_key) -> None:
"""После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом,
если вдруг была, уступает место)."""
if get_note(conn, old_key) is None:
return
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,))
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
```
- [ ] **Step 4: Запустить — зелёный**
Run: `uv run pytest tests/test_mirror.py -v`
Expected: 5 passed
- [ ] **Step 5: Коммит**
```bash
git add src/jira_hub/mirror.py tests/test_mirror.py
git commit -m "feat(hub): зеркало задач, связи, служебные заметки
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import pytest
from jira_hub import commands, db, mirror
from jira_hub.commands import CommandError
def test_validate_ok_and_tail_keys():
cmds = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}},
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
{"type": "refresh", "params": {"keys": ["A-1", "new:60"]}},
])
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"]
assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"]
assert commands.temp_keys_used(cmds[1]) == ["new:60"]
assert commands.temp_keys_used(cmds[0]) == []
assert commands.temp_keys_used(cmds[2]) == ["new:60"]
@pytest.mark.parametrize("bad, msg", [
([], "без команд"),
([{"type": "ensure_all"}], "неизвестный тип"),
([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"),
([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"),
([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"),
([{"type": "refresh", "params": {}}], "без keys и jql"),
([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"),
([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:<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`:
```python
"""Контракт команд: каждая — желаемый результат на одной задаче 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: Коммит**
```bash
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`:
```python
import pytest
from jira_hub import db, mirror, queue, settings
from jira_hub.commands import CommandError
from jira_hub.queue import ClaimedError
LT = {"связанные": "ASUPR_Связанные"}
def fresh():
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", LT)
return conn
def fields(key, **extra):
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
def test_create_batch_adds_tail_refresh_and_estimate():
conn = fresh()
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
view = queue.batch_view(conn, b["id"])
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
assert view["commands"][2]["params"] == {"keys": ["A-1", "A-2"]}
assert b["estimate_seconds"] == 120 + 30 + 2 and b["count"] == 3
assert view["status"] == "queued" and view["author"] == "claude"
def test_refresh_only_batch_has_no_tail():
conn = fresh()
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
assert b["count"] == 1
with pytest.raises(CommandError):
queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
def test_claim_order_pause_and_report():
conn = fresh()
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
c = queue.claim(conn, agent_version=1, settings_version=1)
assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"]
assert queue.claim(conn) is None # одна команда в полёте
assert queue.agent_view(conn)["current_command_id"] == c["id"]
done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2)
assert done["status"] == "done" and done["jira_calls"] == 2
again = queue.report_result(conn, c["id"], "failed", error="поздно")
assert again["status"] == "done" # повторный отчёт не меняет
tail = queue.claim(conn)
assert tail["type"] == "refresh" and tail["pause_seconds"] == 2
assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1"
queue.record_state(conn, tail["id"], 1, 0)
queue.report_result(conn, tail["id"], "done", jira_calls=1)
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()
assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1
assert queue.batch_view(conn, b1["id"])["status"] == "done"
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
def test_claim_is_exclusive():
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
first, second = queue.claim(conn), queue.claim(conn)
assert first is not None and second is None
def test_stale_claimed_does_not_block():
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn)
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
nxt = queue.claim(conn)
assert nxt["params"]["key"] == "A-2"
assert queue.agent_view(conn)["stuck"][0]["id"] == c["id"]
def test_temp_key_substitution_and_skip():
conn = fresh()
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
queue.create_batch(conn, "создать", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
{"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая"}},
{"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}},
])
c1 = queue.claim(conn)
queue.report_result(conn, c1["id"], "done", result={"key": "A-90"})
assert mirror.get_note(conn, "A-90")["fact_status"] == "В работе" and mirror.get_note(conn, "new:60") is None
c2 = queue.claim(conn)
assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90"
queue.report_result(conn, c2["id"], "done")
c3 = queue.claim(conn)
queue.report_result(conn, c3["id"], "failed", error="HTTP 400")
tail = queue.claim(conn) # ensure_sprint пропущена, refresh без new:61
assert tail["type"] == "refresh" and tail["params"]["keys"] == ["A-90", "A-1"]
view = queue.batch_view(conn, c1["batch_id"])
assert [c["status"] for c in view["commands"]][:4] == ["done", "done", "failed", "skipped"]
assert view["commands"][3]["error"] == "задача new:61 не создана"
def test_cancel_and_retry():
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn)
with pytest.raises(ClaimedError):
queue.cancel_command(conn, c["id"])
view = queue.cancel_batch(conn, b["id"])
assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"]
assert queue.claim(conn) is None
forced = queue.cancel_command(conn, c["id"], force=True)
assert forced["status"] == "cancelled"
after = queue.report_result(conn, c["id"], "done", result={"changed": []})
assert after["status"] == "cancelled" and after["result"] is None # отчёт после force-отмены не затирает
assert queue.batch_view(conn, b["id"])["status"] == "cancelled"
r = queue.retry_command(conn, c["id"], "claude")
assert queue.batch_view(conn, r["id"])["title"] == f"Повтор команды {c['id']}"
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
def test_report_after_force_cancel_keeps_cancelled():
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn)
queue.cancel_command(conn, c["id"], force=True)
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
def test_reconcile_confirms_by_state():
conn = fresh()
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn)
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
queue.report_result(conn, r["id"], "done")
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
assert r["batch_id"] == c["batch_id"] # выдался хвостовой refresh первого пакета
assert queue.batch_view(conn, c["batch_id"])["status"] == "done"
assert queue.batch_view(conn, b["id"])["status"] == "queued"
def test_auto_refresh_conditions():
conn = fresh()
assert queue.maybe_auto_refresh(conn) is None # агент не на связи
queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста
b = queue.maybe_auto_refresh(conn)
assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление"
assert queue.maybe_auto_refresh(conn) is None # очередь занята
c = queue.claim(conn)
queue.report_result(conn, c["id"], "done")
assert queue.maybe_auto_refresh(conn) is None # интервал не прошёл
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
assert queue.maybe_auto_refresh(conn) is not None
settings.set_(conn, "auto_refresh_minutes", 0)
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
assert queue.maybe_auto_refresh(conn) is None
def test_backup_called_for_first_write_only(tmp_path):
conn = fresh()
calls = []
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c1 = queue.claim(conn, backup=calls.append)
queue.report_result(conn, c1["id"], "done")
queue.claim(conn, backup=calls.append)
assert calls == [f"batch-{c1['batch_id']}"]
def test_no_backup_for_refresh_batch():
conn = fresh()
calls = []
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
queue.claim(conn, backup=calls.append)
assert calls == []
def test_make_backup_rotates(tmp_path):
from jira_hub import backup
conn = db.connect(tmp_path / "hub.sqlite")
for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
```
- [ ] **Step 2: Запустить — падает**
Run: `uv run pytest tests/test_queue.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.queue'`
- [ ] **Step 3: Добавить в db.py**
В конец `src/jira_hub/db.py`:
```python
def utcnow() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def parse(ts: str) -> datetime:
"""ISO-8601 из базы → aware datetime (naive считаем UTC)."""
d = datetime.fromisoformat(ts)
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
```
- [ ] **Step 4: backup.py**
`src/jira_hub/backup.py`:
```python
"""Резервная копия файла базы перед пакетом с записями в Jira: VACUUM INTO даёт согласованный снимок при WAL."""
from datetime import datetime
from pathlib import Path
def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path:
backups_dir = Path(backups_dir)
backups_dir.mkdir(parents=True, exist_ok=True)
target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite"
conn.execute("VACUUM INTO ?", (str(target),))
files = sorted(backups_dir.glob("*.sqlite"))
if keep > 0:
for old in files[:-keep]:
old.unlink()
return target
```
- [ ] **Step 5: queue.py**
`src/jira_hub/queue.py`:
```python
"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
Хаб никогда не повторяет команды сам (design.md, решение 4)."""
import json
from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import commands as C
from . import db, mirror, settings
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа»
ONLINE_MINUTES = 5
class ClaimedError(Exception):
def __init__(self, claimed_at):
super().__init__(f"команда взята агентом {claimed_at}; отмена — с force")
self.claimed_at = claimed_at
def _cmd(row) -> dict:
d = dict(row)
d["params"] = json.loads(d["params"])
d["result"] = json.loads(d["result"]) if d.get("result") else None
return d
def get_command(conn, command_id) -> dict | None:
row = conn.execute("SELECT * FROM command WHERE id = ?", (command_id,)).fetchone()
return _cmd(row) if row else None
def _dump(value) -> str:
return json.dumps(value, ensure_ascii=False)
# --- пакеты -----------------------------------------------------------------
def create_batch(conn, title, author, cmds) -> dict:
cmds = C.validate(cmds)
keys = []
for c in cmds:
keys += [k for k in C.referenced_keys(c) if k not in keys]
if any(c["type"] != "refresh" for c in cmds) and keys:
cmds.append({"type": "refresh", "params": {"keys": keys}})
est = C.estimate([c["type"] for c in cmds], settings.pacing(conn))
with db.transaction(conn):
cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds) VALUES (?, ?, ?, ?)",
(title, author or "api", db.now(), est))
batch_id = cur.lastrowid
defined = {}
for seq, c in enumerate(cmds, 1):
deps = [defined[k] for k in C.temp_keys_used(c) if k in defined]
cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, depends_on) VALUES (?, ?, ?, ?, ?)",
(batch_id, seq, c["type"], _dump(c["params"]), deps[0] if deps else None))
if c["type"] == "ensure_issue":
defined[c["params"]["temp_key"]] = cur.lastrowid
return {"id": batch_id, "estimate_seconds": est, "count": len(cmds)}
def _status_of(cmds, cancelled_at=None) -> str:
sts = [c["status"] for c in cmds]
if not sts:
return "done"
if all(s in TERMINAL for s in sts):
return "cancelled" if cancelled_at and all(s == "cancelled" for s in sts) else "done"
if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts):
return "running"
return "queued"
def batch_view(conn, batch_id, with_commands=True) -> dict | None:
b = conn.execute("SELECT * FROM batch WHERE id = ?", (batch_id,)).fetchone()
if b is None:
return None
cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))]
pacing = settings.pacing(conn)
d = dict(b)
d["status"] = _status_of(cmds, b["cancelled_at"])
d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})}
d["remaining_seconds"] = C.estimate([c["type"] for c in cmds if c["status"] in ("queued", "claimed")], pacing)
if with_commands:
d["commands"] = cmds
return d
def list_batches(conn, limit=20) -> list[dict]:
ids = [r["id"] for r in conn.execute("SELECT id FROM batch ORDER BY id DESC LIMIT ?", (int(limit),))]
return [batch_view(conn, i, with_commands=False) for i in ids]
# --- выдача -----------------------------------------------------------------
def _mapping(conn, batch_id) -> dict:
"""temp_key → настоящий ключ по завершённым ensure_issue пакета."""
rows = conn.execute("SELECT json_extract(params, '$.temp_key') AS t, json_extract(result, '$.key') AS k FROM command "
"WHERE batch_id = ? AND type = 'ensure_issue' AND status IN ('done', 'confirmed_by_state')",
(batch_id,)).fetchall()
return {r["t"]: r["k"] for r in rows if r["k"]}
def _definer_status(conn, batch_id, temp_key):
r = conn.execute("SELECT status FROM command WHERE batch_id = ? AND type = 'ensure_issue' "
"AND json_extract(params, '$.temp_key') = ?", (batch_id, temp_key)).fetchone()
return r["status"] if r else "failed"
def _next(conn) -> dict | None:
rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id "
"WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall()
for row in rows:
cmd = _cmd(row)
needed = C.temp_keys_used(cmd)
if needed:
mapping = _mapping(conn, cmd["batch_id"])
unresolved = [k for k in needed if k not in mapping]
pending = [k for k in unresolved if _definer_status(conn, cmd["batch_id"], k) not in TERMINAL]
if pending:
return None # создание ещё не завершено — ждём
cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping)
if cmd["type"] == "refresh":
cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)]
unresolved = [] if cmd["params"]["keys"] else unresolved
if unresolved:
conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?",
(db.now(), f"задача {unresolved[0]} не создана", cmd["id"]))
continue
return cmd
return None
def _fresh_claimed_exists(conn) -> bool:
cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat()
return conn.execute("SELECT 1 FROM command WHERE status = 'claimed' AND claimed_at > ? LIMIT 1", (cutoff,)).fetchone() is not None
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None:
now = db.now()
to_backup = None
with db.transaction(conn):
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), "
"settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1",
(now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at))
if _fresh_claimed_exists(conn):
return None
cmd = _next(conn)
if cmd is None:
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1")
return None
pause = int(settings.pacing(conn).get(cmd["type"], 0))
first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'",
(cmd["batch_id"],)).fetchone()["n"] == 0
has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1",
(cmd["batch_id"],)).fetchone() is not None
conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ? WHERE id = ?",
(now, pause, _dump(cmd["params"]), cmd["id"]))
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],))
if cmd["type"] == "refresh":
p = cmd["params"]
conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)",
(now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"]))
if first and has_writes:
to_backup = f"batch-{cmd['batch_id']}"
if to_backup and backup:
backup(to_backup)
return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]}
# --- отчёты -----------------------------------------------------------------
def record_state(conn, command_id, issues_count, links_count) -> None:
conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?",
(int(issues_count), int(links_count), command_id))
def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict:
if status not in ("done", "failed"):
raise ValueError("status — done или failed")
with db.transaction(conn):
cmd = get_command(conn, command_id)
if cmd is None:
raise KeyError(command_id)
if cmd["status"] in TERMINAL:
return cmd # идемпотентно; cancelled не затирается
now = db.now()
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
(status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id))
conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?",
(now, command_id))
if cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key"):
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
if cmd["type"] == "refresh":
conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?",
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
if cmd["type"] == "refresh" and status == "done":
reconcile(conn)
return get_command(conn, command_id)
def reconcile(conn) -> list[int]:
"""Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state."""
link_types = settings.get(conn, "link_types") or {}
changed = []
for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall():
cmd = _cmd(row)
ok, result = C.check_confirmed(conn, cmd, link_types)
if not ok:
continue
conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? WHERE id = ?",
(db.now(), _dump(result) if result else None, cmd["id"]))
if cmd["type"] == "ensure_issue" and result:
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
changed.append(cmd["id"])
return changed
# --- отмена и повтор ----------------------------------------------------------
def cancel_batch(conn, batch_id) -> dict:
with db.transaction(conn):
if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None:
raise KeyError(batch_id)
now = db.now()
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id))
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id))
return batch_view(conn, batch_id)
def cancel_command(conn, command_id, force=False) -> dict:
with db.transaction(conn):
cmd = get_command(conn, command_id)
if cmd is None:
raise KeyError(command_id)
if cmd["status"] == "claimed" and not force:
raise ClaimedError(cmd["claimed_at"])
if cmd["status"] in ("queued", "claimed"):
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE id = ?", (db.now(), command_id))
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (command_id,))
return get_command(conn, command_id)
def retry_command(conn, command_id, author) -> dict:
cmd = get_command(conn, command_id)
if cmd is None:
raise KeyError(command_id)
return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}])
# --- агент и автообновление ----------------------------------------------------
def agent_view(conn) -> dict:
d = dict(conn.execute("SELECT * FROM agent_status WHERE id = 1").fetchone())
now = db.utcnow()
d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
min_version = int(settings.get(conn, "min_agent_version") or 0)
d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None
d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?",
((now - timedelta(days=1)).isoformat(),)).fetchone()["n"]
cutoff = (now - timedelta(minutes=STALE_MINUTES)).isoformat()
d["stuck"] = [dict(r) for r in conn.execute(
"SELECT id, type, claimed_at FROM command WHERE status = 'claimed' AND claimed_at <= ? ORDER BY id", (cutoff,))]
return d
def maybe_auto_refresh(conn) -> dict | None:
minutes = int(settings.get(conn, "auto_refresh_minutes") or 0)
if minutes <= 0 or not agent_view(conn)["online"]:
return None
if conn.execute("SELECT 1 FROM command WHERE status = 'queued' LIMIT 1").fetchone() or _fresh_claimed_exists(conn):
return None
last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"]
if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes):
return None
return create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": settings.get(conn, "base_jql")}}])
```
- [ ] **Step 6: Запустить — зелёный**
Run: `uv run pytest tests/test_queue.py -v`
Expected: 13 passed. Если `test_temp_key_substitution_and_skip` падает на порядке ключей хвостового `refresh` — проверить, что `referenced_keys` обходит поля в порядке `SPEC[...]["keys"]`, а `create_batch` сохраняет порядок первого появления.
- [ ] **Step 7: Весь набор и коммит**
Run: `uv run pytest -q`
Expected: все зелёные
```bash
git add src/jira_hub/queue.py src/jira_hub/backup.py src/jira_hub/db.py tests/test_queue.py
git commit -m "feat(hub): очередь команд — пакеты, выдача, отчёты, отмена, сверка, автообновление, резервные копии
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import threading
import pytest
from jira_hub import api, db, settings
from jira_hub.client import HubClient, HubError
@pytest.fixture
def hub(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
tok = settings.tokens(conn)
conn.close()
srv = api.make_server(h, "127.0.0.1", 0)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"
yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base}
srv.shutdown()
def test_auth_scopes(hub):
with pytest.raises(HubError) as e:
hub["agent"].get("/issues")
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["client"].post("/agent/claim", {})
assert e.value.status == 401
with pytest.raises(HubError) as e:
HubClient(hub["base"], "wrong").get("/issues")
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["client"].get("/nope")
assert e.value.status == 404
def test_agent_flow(hub):
c = hub["client"]
b = c.post("/batches", {"title": "срок", "author": "claude",
"commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
assert b["count"] == 2
a = hub["agent"]
claim = a.post("/agent/claim", {"agent_version": 1, "settings_version": 1})
cmd = claim["command"]
assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1
assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"]
assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные"
assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен
r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2})
assert r["status"] == "done"
assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done"
tail = a.post("/agent/claim", {})["command"]
assert tail["type"] == "refresh"
out = a.post("/agent/state", {"command_id": tail["id"], "page": 1, "last_page": True,
"issues": [{"key": "A-1", "summary": "Т", "duedate": "2026-10-23", "labels": ["AWPCA"],
"created": "2026-10-10T10:00:00.000+0300"},
{"key": "B-1", "summary": "Сосед", "labels": []}],
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
"neighbor_keys": ["B-1"]})
assert out == {"issues": 2, "links": 1}
assert a.get("/agent/known-keys")["keys"] == ["A-1", "B-1"]
a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1})
assert c.get("/batches/%d" % b["id"])["status"] == "done"
issue = c.get("/issues/A-1")
assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}]
assert c.get("/issues/B-1")["neighbor"] is True
runs = c.get("/sync-runs", limit=5)["runs"]
assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"]
assert c.get("/agent")["online"] is True
def test_interface_routes(hub):
c = hub["client"]
with pytest.raises(HubError) as e:
c.get("/issues/A-404")
assert e.value.status == 404
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []},
{"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []})
assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"]
note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"})
assert note["fact_status"] == "В работе" and note["updated_by"] == "owner"
with pytest.raises(HubError) as e:
c.put("/issues/A-1/note", {"summary": "x"})
assert e.value.status == 422
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1"}}]})
assert e.value.status == 422 and "без полей" in e.value.detail
b = c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}},
{"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]})
assert c.get("/batches", limit=5)["batches"][0]["id"] == b["id"]
cmd = hub["agent"].post("/agent/claim", {})["command"]
with pytest.raises(HubError) as e:
c.post(f"/commands/{cmd['id']}/cancel", {})
assert e.value.status == 409 and e.value.detail
assert c.post(f"/commands/{cmd['id']}/cancel", {"force": True})["status"] == "cancelled"
assert c.post(f"/batches/{b['id']}/cancel", {})["status"] == "cancelled"
retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"})
assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}"
full = c.post("/refresh", {"full": True})
assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels = AWPCA"
keys = c.post("/refresh", {"keys": ["A-1"]})
assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]}
def test_settings_put_rejects_protected(hub):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
for key in ("tokens", "settings_version"):
with pytest.raises(HubError) as e:
c.put("/settings", {key: "x", "pacing": {"refresh": 1}})
assert e.value.status == 422
assert c.get("/settings")["settings_version"] == v0
assert "tokens" not in c.get("/settings")
v1 = c.put("/settings", {"pacing": {"refresh": 1}})["settings_version"]
assert v1 == v0 + 1 and c.get("/settings")["pacing"]["refresh"] == 1
def test_backup_created_on_first_write(hub, tmp_path):
c = hub["client"]
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
hub["agent"].post("/agent/claim", {})
assert len(list((tmp_path / "backups").glob("*-batch-*.sqlite"))) == 1
def test_client_unavailable():
from jira_hub.client import HubUnavailable
with pytest.raises(HubUnavailable):
HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues")
```
- [ ] **Step 2: Запустить — падает**
Run: `uv run pytest tests/test_api.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.api'`
- [ ] **Step 3: client.py**
`src/jira_hub/client.py`:
```python
"""HTTP-клиент хаба: для MCP, тестов и скриптов. Ошибки API → HubError, недоступный хаб → HubUnavailable."""
import json
import urllib.error
import urllib.parse
import urllib.request
from . import db, paths, settings
class HubError(Exception):
def __init__(self, status, error, detail=None):
super().__init__(f"HTTP {status} {error}: {detail or ''}".strip())
self.status, self.error, self.detail = status, error, detail
class HubUnavailable(Exception):
"""Хаб не отвечает — обычно не запущен (jira-hub serve)."""
class HubClient:
def __init__(self, base_url: str, token: str, timeout: float = 60):
self.base, self.token, self.timeout = base_url.rstrip("/"), token, timeout
@classmethod
def from_home(cls, timeout: float = 60) -> "HubClient":
conn = db.connect(paths.db_path())
try:
settings.ensure_defaults(conn)
return cls(f"http://127.0.0.1:{settings.get(conn, 'port')}", settings.tokens(conn)["client"], timeout)
finally:
conn.close()
def call(self, method: str, path: str, body=None, params=None):
url = self.base + path
if params:
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + self.token)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", "application/json; charset=utf-8")
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
return json.loads(raw.decode("utf-8")) if raw else {}
except urllib.error.HTTPError as e:
try:
payload = json.loads(e.read().decode("utf-8"))
except Exception:
payload = {"error": "http", "detail": str(e)}
raise HubError(e.code, payload.get("error", "http"), payload.get("detail")) from None
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
raise HubUnavailable(f"хаб не отвечает ({self.base}): {e}") from None
def get(self, path, **params):
return self.call("GET", path, params=params or None)
def post(self, path, body=None):
return self.call("POST", path, body if body is not None else {})
def put(self, path, body):
return self.call("PUT", path, body)
```
- [ ] **Step 4: api.py**
`src/jira_hub/api.py`:
```python
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md)."""
import json
import re
import threading
import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, settings
from .commands import CommandError
from .paths import backups_dir as default_backups_dir
from .queue import ClaimedError
class ApiError(Exception):
def __init__(self, status, error, detail=None):
super().__init__(error)
self.status, self.error, self.detail = status, error, detail
class Hub:
def __init__(self, db_path, backups_dir=None):
self.db_path = Path(db_path)
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.claim_wait = 30
conn = self.conn()
settings.ensure_defaults(conn)
conn.close()
def conn(self):
return db.connect(self.db_path)
def make_backup(self, label):
conn = self.conn()
try:
backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30))
finally:
conn.close()
# --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ----------
def _int(m, i=1):
return int(m.group(i))
def _not_found(obj, what):
if obj is None:
raise ApiError(404, "not_found", what)
return obj
def h_claim(hub, conn, m, q, body):
deadline = time.monotonic() + hub.claim_wait
while True:
cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"),
backup=hub.make_backup)
if cmd or time.monotonic() >= deadline:
break
time.sleep(1)
return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")}
def h_agent_settings(hub, conn, m, q, body):
return 200, settings.for_agent(conn)
def h_agent_command(hub, conn, m, q, body):
return 200, _not_found(queue.get_command(conn, _int(m)), "command")
def h_agent_result(hub, conn, m, q, body):
try:
cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"),
body.get("jira_calls", 0), body.get("rate_limited", 0))
except KeyError:
raise ApiError(404, "not_found", "command")
except ValueError as e:
raise ApiError(422, "bad_status", str(e))
return 200, cmd
def h_agent_state(hub, conn, m, q, body):
out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [])
if body.get("command_id"):
queue.record_state(conn, body["command_id"], out["issues"], out["links"])
return 200, out
def h_known_keys(hub, conn, m, q, body):
return 200, {"keys": mirror.known_keys(conn)}
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q")
def h_issues(hub, conn, m, q, body):
filters = {k: q.get(k) for k in ISSUE_FILTERS}
return 200, {"issues": mirror.list_issues(conn, filters, int(q.get("limit", 50)), int(q.get("offset", 0)))}
def h_issue(hub, conn, m, q, body):
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"}
try:
return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api")
except ValueError as e:
raise ApiError(422, "bad_note", str(e))
def h_batches_post(hub, conn, m, q, body):
if not body.get("title"):
raise ApiError(422, "bad_batch", "нет title")
try:
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"))
except CommandError as e:
raise ApiError(422, "bad_batch", str(e))
def h_batches_get(hub, conn, m, q, body):
return 200, {"batches": queue.list_batches(conn, int(q.get("limit", 20)))}
def h_batch_get(hub, conn, m, q, body):
return 200, _not_found(queue.batch_view(conn, _int(m)), "batch")
def h_batch_cancel(hub, conn, m, q, body):
try:
return 200, queue.cancel_batch(conn, _int(m))
except KeyError:
raise ApiError(404, "not_found", "batch")
def h_command_retry(hub, conn, m, q, body):
try:
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api")
except KeyError:
raise ApiError(404, "not_found", "command")
except CommandError as e:
raise ApiError(422, "bad_batch", str(e))
def h_command_cancel(hub, conn, m, q, body):
try:
return 200, queue.cancel_command(conn, _int(m), bool(body.get("force")))
except KeyError:
raise ApiError(404, "not_found", "command")
except ClaimedError as e:
raise ApiError(409, "claimed", e.claimed_at)
def h_refresh(hub, conn, m, q, body):
if body.get("full"):
params = {"jql": settings.get(conn, "base_jql")}
elif body.get("jql"):
params = {"jql": body["jql"]}
elif body.get("keys"):
params = {"keys": list(body["keys"])}
else:
raise ApiError(422, "bad_refresh", "нужно full, jql или keys")
return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}])
def h_agent_get(hub, conn, m, q, body):
return 200, queue.agent_view(conn)
def h_sync_runs(hub, conn, m, q, body):
rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int(q.get("limit", 20)),)).fetchall()
return 200, {"runs": [dict(r) for r in rows]}
def h_settings_get(hub, conn, m, q, body):
return 200, settings.get_all(conn)
def h_settings_put(hub, conn, m, q, body):
bad = [k for k in body if k in settings.PROTECTED]
if bad:
raise ApiError(422, "protected", ", ".join(bad))
version = settings.get(conn, "settings_version")
for key, value in body.items():
version = settings.set_(conn, key, value)
return 200, {"settings_version": version}
ROUTES = [
("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
("POST", r"^/agent/state$", h_agent_state, "agent"),
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"),
("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
("POST", r"^/refresh$", h_refresh, "client"),
("GET", r"^/agent$", h_agent_get, "client"),
("GET", r"^/sync-runs$", h_sync_runs, "client"),
("GET", r"^/settings$", h_settings_get, "client"),
("PUT", r"^/settings$", h_settings_put, "client"),
]
_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES]
class Handler(BaseHTTPRequestHandler):
hub: Hub = None # подставляется в make_server
def do_GET(self):
self._dispatch("GET")
def do_POST(self):
self._dispatch("POST")
def do_PUT(self):
self._dispatch("PUT")
def log_message(self, fmt, *args): # тише стандартного: без адреса клиента
print(f"[{self.log_date_time_string()}] {fmt % args}")
def _auth(self, scope):
token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
conn = self.hub.conn()
try:
tokens = settings.tokens(conn)
finally:
conn.close()
if not token or token != tokens["agent" if scope == "agent" else "client"]:
raise ApiError(401, "unauthorized", "неверный токен для этого маршрута")
def _dispatch(self, method):
url = urlparse(self.path)
query = {k: v[0] for k, v in parse_qs(url.query).items()}
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b""
try:
body = json.loads(raw.decode("utf-8")) if raw else {}
if not isinstance(body, dict):
raise ApiError(400, "bad_json", "ожидается объект")
for route_method, rx, fn, scope in _COMPILED:
if route_method != method:
continue
match = rx.match(url.path)
if not match:
continue
self._auth(scope)
conn = self.hub.conn()
try:
status, payload = fn(self.hub, conn, match, query, body)
finally:
conn.close()
return self._send(status, payload)
raise ApiError(404, "not_found", url.path)
except ApiError as e:
self._send(e.status, {"error": e.error, "detail": e.detail})
except json.JSONDecodeError as e:
self._send(400, {"error": "bad_json", "detail": str(e)})
except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе
traceback.print_exc()
self._send(500, {"error": "internal", "detail": str(e)})
def _send(self, status, payload):
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer:
handler = type("HubHandler", (Handler,), {"hub": hub})
return ThreadingHTTPServer((host, port), handler)
def serve(hub: Hub, host="0.0.0.0", port=8790) -> None:
srv = make_server(hub, host, port)
stop = threading.Event()
def ticker():
while not stop.wait(60):
conn = hub.conn()
try:
queue.maybe_auto_refresh(conn)
except Exception: # noqa: BLE001
traceback.print_exc()
finally:
conn.close()
threading.Thread(target=ticker, daemon=True).start()
print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}")
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
stop.set()
srv.server_close()
```
- [ ] **Step 5: Запустить — зелёный**
Run: `uv run pytest tests/test_api.py -v`
Expected: 6 passed
- [ ] **Step 6: Коммит**
```bash
git add src/jira_hub/api.py src/jira_hub/client.py tests/test_api.py
git commit -m "feat(hub): HTTP API — маршруты агента и интерфейсов, токены, долгий claim, клиент
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import csv
from jira_hub import db, importer, mirror, settings
def write_csv(path, header, rows):
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(header)
w.writerows(rows)
def make_package(folder):
write_csv(folder / "tasks.csv",
["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации",
"Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"],
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", "Актуализация 10.10.2026. Порядок",
"", "Прогнать на стенде", "Спринт 2"],
["AURORA-1416", "Карточка", "UI", "В работе", "", "Сделан список", "Модалка", ""]])
write_csv(folder / "new_tasks.csv",
["Ключ", "Тема", "Вид работы", "Статус по факту", "Описание", "Основание (внутр.)", "Спринт"],
[["AURORA-1586", "Справочники: тестирование", "тестирование", "Ожидание разработки", "h3. Контекст", "решение 11.10", "Спринт 2"],
["", "Ещё не создана", "разработка", "Ожидание разработки", "h3. Контекст", "идея", ""]])
write_csv(folder / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"],
[["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"], ["NEW-1", "AURORA-1529", "Роли", "новый"]])
write_csv(folder / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
[["AURORA-1533", "блокирует начало", "AURORA-1416", "x"],
["new:2", "связанные", "AURORA-1533", "y"],
["new:3", "связанные", "AURORA-1533", "z"]])
def test_import_notes_and_report(tmp_path):
make_package(tmp_path)
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
rep = importer.import_package(conn, tmp_path)
assert rep["notes"] == 6
n = mirror.get_note(conn, "AURORA-1533")
assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование"
assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import"
assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список"
assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10"
assert mirror.get_note(conn, "new:3")["owner_note"] == "Ещё не создана"
assert mirror.get_note(conn, "AURORA-1529")["work_kind"] == "эпик"
assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"]
assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
assert importer.import_package(conn, tmp_path)["notes"] == 6
assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6
```
- [ ] **Step 2: Запустить — падает**
Run: `uv run pytest tests/test_importer.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.importer'`
- [ ] **Step 3: Реализация**
`src/jira_hub/importer.py`:
```python
"""Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор
(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует."""
import csv
from pathlib import Path
from . import mirror, settings
def _rows(path: Path) -> list[dict]:
if not path.exists():
return []
with open(path, encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def _clean(value) -> str | None:
value = (value or "").strip()
return value or None
def import_package(conn, folder) -> dict:
folder = Path(folder)
notes = 0
csv_keys = []
for r in _rows(folder / "tasks.csv"):
key = _clean(r.get("Код"))
if not key:
continue
mirror.set_note(conn, key, {
"fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")),
"done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")),
"owner_note": _clean(r.get("Комментарий актуализации"))}, "import")
notes += 1
csv_keys.append(key)
new_keys = {} # new:<строка> → ключ Jira (как в jira_sync.py)
for i, r in enumerate(_rows(folder / "new_tasks.csv"), 2):
real = _clean(r.get("Ключ"))
key = real or f"new:{i}"
if real:
new_keys[f"new:{i}"] = real
csv_keys.append(real)
mirror.set_note(conn, key, {
"fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")),
"rest_text": _clean(r.get("Основание (внутр.)")),
"owner_note": None if real else _clean(r.get("Тема"))}, "import")
notes += 1
for r in _rows(folder / "epics.csv"):
key = _clean(r.get("Ключ"))
if not key:
continue
mirror.set_note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}, "import")
notes += 1
csv_keys.append(key)
link_types = settings.get(conn, "link_types") or {}
expected, missing_links = [], []
for r in _rows(folder / "links.csv"):
src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда")))
dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда")))
kind = (_clean(r.get("Тип")) or "").lower()
if not src or not dst or src.startswith("new:") or dst.startswith("new:"):
continue
link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)}
expected.append(link)
issue = mirror.get_issue(conn, src)
present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"])
if not present:
missing_links.append(link)
known = set(mirror.known_keys(conn))
missing_keys = sorted({k for k in csv_keys if k not in known})
return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links}
```
- [ ] **Step 4: Запустить — зелёный**
Run: `uv run pytest tests/test_importer.py -v`
Expected: 1 passed
- [ ] **Step 5: Коммит**
```bash
git add src/jira_hub/importer.py tests/test_importer.py
git commit -m "feat(hub): импорт CSV-пакета admin/jira-sync в заметки, сверка связей
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import json
import threading
import pytest
from jira_hub import api, mcp_server, settings
from jira_hub.client import HubClient
@pytest.fixture
def live(tmp_path, monkeypatch):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
tok = settings.tokens(conn)
conn.close()
srv = api.make_server(h, "127.0.0.1", 0)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
yield {"agent": HubClient(base, tok["agent"])}
srv.shutdown()
def test_offline_message(monkeypatch):
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert mcp_server.issues() == mcp_server.OFFLINE
assert mcp_server.agent_status() == mcp_server.OFFLINE
def test_tools_roundtrip(live):
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23",
"assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []})
out = mcp_server.issues(sprint="Спринт 2")
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе")
detail = mcp_server.issue("A-1")
assert "fact_status: В работе" in detail and "Готово" in detail
created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}])
batch_id = int(created.split("#")[1].split()[0])
assert "2 команд" in created and "мин" in created
bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}])
assert bad.startswith("ошибка хаба 422")
cmd = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"})
tail = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"})
waited = mcp_server.batch_wait(batch_id, timeout_minutes=1)
assert "done" in waited and "failed" in waited and "HTTP 400" in waited
assert f"#{batch_id}" in mcp_server.batches(limit=5)
assert "Повтор команды" in mcp_server.command_retry(cmd["id"])
assert "cancelled" in mcp_server.batch_cancel(batch_id + 1)
r = mcp_server.refresh(full=True, wait=False)
assert "Обновление" in r or "#" in r
assert "онлайн" in mcp_server.agent_status()
assert "A-1" in mcp_server.sync_log(limit=3) or "keys" in mcp_server.sync_log(limit=3)
assert "120" in mcp_server.settings_get("pacing")
assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1}))
assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
def test_formatters():
assert mcp_server.fmt_issues([]) == "задач нет"
text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
"sprint": None, "fact_status": None}])
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
"rate_limited_24h": 0, "stuck": [], "warning": None, "last_seen_at": None,
"settings_version": None})
```
- [ ] **Step 2: Запустить — падает**
Run: `uv run pytest tests/test_mcp.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.mcp_server'`
- [ ] **Step 3: Реализация**
`src/jira_hub/mcp_server.py`:
```python
"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md).
Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение."""
import functools
import json
import time
from mcp.server.fastmcp import FastMCP
from .client import HubClient, HubError, HubUnavailable
OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5
AUTHOR = "claude"
mcp = FastMCP("jira-hub")
def _client() -> HubClient:
return HubClient.from_home()
def guard(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ")
return wrapper
# --- форматирование --------------------------------------------------------------
def _cut(text, n=80):
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"
def _v(value):
return "—" if value in (None, "", []) else str(value)
def fmt_issues(issues) -> str:
if not issues:
return "задач нет"
lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"]
for i in issues:
lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")),
_v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))]))
return "\n".join(lines)
def fmt_issue(d) -> str:
head = [f"{d['key']} — {d.get('summary')}",
f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; "
f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}",
f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; "
f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"]
links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
note = d.get("note") or {}
notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"])
+ ["описание:", d.get("description") or "—"])
def fmt_batch(b) -> str:
est = round((b.get("remaining_seconds") or 0) / 60)
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
+ ", ".join(f"{k}: {v}" for k, v in (b.get("counts") or {}).items())]
for c in b.get("commands", []):
lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}"
+ (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else ""))
return "\n".join(lines)
def fmt_agent(a) -> str:
state = "онлайн" if a.get("online") else "офлайн"
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"
out = [f"агент {state} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(a.get('last_seen_at'))}); {cur}; "
f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"]
if a.get("warning"):
out.append("внимание: " + a["warning"])
for s in a.get("stuck", []):
out.append(f"нет ответа от агента: #{s['id']} {s['type']} с {s['claimed_at']}")
return "\n".join(out)
# --- инструменты -----------------------------------------------------------------
@mcp.tool()
@guard
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "",
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
data = _client().get("/issues", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status,
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
return fmt_issues(data["issues"])
@mcp.tool()
@guard
def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание."""
return fmt_issue(_client().get(f"/issues/{key}"))
@mcp.tool()
@guard
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None,
rest_text: str | None = None, owner_note: str | None = None) -> str:
"""Правка служебных заметок задачи (только переданные поля)."""
fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text,
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
fields["updated_by"] = AUTHOR
note = _client().put(f"/issues/{key}/note", fields)
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
@mcp.tool()
@guard
def batch_create(title: str, commands: list[dict]) -> str:
"""Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status|
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам."""
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
@mcp.tool()
@guard
def batch_status(id: int) -> str:
"""Состояние пакета и его команд."""
return fmt_batch(_client().get(f"/batches/{id}"))
@mcp.tool()
@guard
def batch_wait(id: int, timeout_minutes: int = 10) -> str:
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам."""
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
while True:
b = _client().get(f"/batches/{id}")
if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline:
return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)")
time.sleep(POLL_SECONDS)
@mcp.tool()
@guard
def batches(limit: int = 10) -> str:
"""Последние пакеты со статусом."""
items = _client().get("/batches", limit=limit)["batches"]
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
@mcp.tool()
@guard
def command_retry(id: int) -> str:
"""Повторить команду — новый пакет с теми же параметрами."""
b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR})
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
@mcp.tool()
@guard
def batch_cancel(id: int) -> str:
"""Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API)."""
return fmt_batch(_client().post(f"/batches/{id}/cancel", {}))
@mcp.tool()
@guard
def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = True) -> str:
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). wait — дождаться результата."""
body = {"full": True} if full or not keys else {"keys": keys}
body["author"] = AUTHOR
b = _client().post("/refresh", body)
return batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен"
@mcp.tool()
@guard
def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа."""
return fmt_agent(_client().get("/agent"))
@mcp.tool()
@guard
def sync_log(limit: int = 10) -> str:
"""Журнал обновлений зеркала."""
runs = _client().get("/sync-runs", limit=limit)["runs"]
return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, "
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}"
for r in runs) or "обновлений не было"
@mcp.tool()
@guard
def settings_get(key: str = "") -> str:
"""Настройки хаба (все или одна по ключу)."""
data = _client().get("/settings")
return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1)
@mcp.tool()
@guard
def settings_set(key: str, value: str) -> str:
"""Записать настройку: value — JSON. Поднимает settings_version."""
out = _client().put("/settings", {key: json.loads(value)})
return f"settings_version = {out['settings_version']}"
def run() -> None:
mcp.run()
```
- [ ] **Step 4: Запустить — зелёный**
Run: `uv run pytest tests/test_mcp.py -v`
Expected: 3 passed. Если FastMCP ругается на `list[dict]` в `batch_create` — заменить аннотацию на `list[dict[str, object]]`.
- [ ] **Step 5: Коммит**
```bash
git add src/jira_hub/mcp_server.py tests/test_mcp.py
git commit -m "feat(hub): MCP-сервер — инструменты поверх HTTP API
Co-Authored-By: Claude Fable 5.1 <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`:
```python
import json
from pathlib import Path
from jira_hub import cli, db, paths, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def test_init_import_tokens(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
out = capsys.readouterr().out
assert "transitions_map" in out and "агентский токен" in out
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
assert "уже инициализирован" in capsys.readouterr().err
conn = db.connect(paths.db_path())
assert settings.get(conn, "board_id") == 4216
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8")
assert cli.main(["import", str(tmp_path / "pkg")]) == 0
rep = json.loads(capsys.readouterr().out)
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"]
assert cli.main(["tokens"]) == 0
assert settings.tokens(conn)["client"] in capsys.readouterr().out
```
- [ ] **Step 2: Запустить — падает**
Run: `uv run pytest tests/test_cli.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'jira_hub.cli'`
- [ ] **Step 3: cli.py**
`src/jira_hub/cli.py`:
```python
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens."""
import argparse
import json
import sys
from . import db, importer, paths, settings
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены")
p.add_argument("--config", required=True)
p.add_argument("--force", action="store_true")
p = sub.add_parser("serve", help="запустить HTTP API")
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int)
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
p.add_argument("folder")
sub.add_parser("mcp", help="MCP-сервер по stdio")
sub.add_parser("tokens", help="показать токены агента и клиента")
args = ap.parse_args(argv)
if args.cmd == "serve":
from . import api
hub = api.Hub(paths.db_path())
conn = hub.conn()
port = args.port or int(settings.get(conn, "port") or 8790)
conn.close()
api.serve(hub, args.host, port)
return 0
if args.cmd == "mcp":
from . import mcp_server
mcp_server.run()
return 0
conn = db.connect(paths.db_path())
settings.ensure_defaults(conn)
try:
if args.cmd == "init":
with open(args.config, encoding="utf-8") as f:
cfg = json.load(f)
try:
keys = settings.init_from_config(conn, cfg, force=args.force)
except RuntimeError as e:
print(e, file=sys.stderr)
return 1
print("перенесено: " + ", ".join(keys))
_print_tokens(conn)
return 0
if args.cmd == "import":
print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1))
return 0
if args.cmd == "tokens":
_print_tokens(conn)
return 0
finally:
conn.close()
return 2
def _print_tokens(conn) -> None:
tok = settings.tokens(conn)
print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}")
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 4: Запустить — зелёный, потом весь набор**
Run: `uv run pytest -q`
Expected: все passed
- [ ] **Step 5: README.md**
`README.md` (содержание, написать полностью по этим пунктам, на русском):
1. Что это: хаб моста Jira (зеркало + очередь + API + MCP), ссылка на `openspec/changes/bridge-core/` и на репозиторий `jira-agent`.
2. Установка: `uv sync`; где лежат данные (`~/.local/share/jira-hub/`, `JIRA_HUB_HOME`).
3. Первый запуск: `uv run jira-hub init --config ../admin/jira-sync/config.example.json` → токены; `uv run jira-hub serve` (порт 8790, адрес Mac mini `192.168.0.106`); как передать агентский токен и адрес на виртуалку (через общую папку, в `agent.json`).
4. Импорт: `uv run jira-hub import ../admin/jira-sync`, как читать отчёт (`missing_keys`, `missing_links`) после первого `refresh full`.
5. MCP: блок для `~/.claude.json` проекта `awp-ca`:
```json
"jira-hub": {"type": "stdio", "command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/awp-ca/jira-hub", "jira-hub", "mcp"]}
```
и список инструментов одной таблицей (из specs/mcp).
6. API: таблица маршрутов (из specs/hub-api) с указанием токена; пример `curl`.
7. Контракт команд: таблица типов с параметрами (из specs/commands), пример пакета JSON.
8. Эксплуатация: резервные копии (`backups/`, `backup_keep`), автообновление (`auto_refresh_minutes`), `PUT /settings`, что делать с «нет ответа от агента» (отменить с `force` или дождаться агента), launchd-plist для автозапуска `serve` (пример `~/Library/LaunchAgents/ru.aepif.jira-hub.plist` с `uv run --project … jira-hub serve`).
9. Тесты: `uv run pytest`.
- [ ] **Step 6: CLAUDE.md и ARCHITECTURE.md**
Заменить `{{...}}` шаблона: что это (абзац из proposal), стек (Python 3.13, uv, stdlib http.server, mcp, pytest), архитектура —
таблица модулей из раздела «Структура файлов» этого плана, команды (`uv run pytest`, `uv run jira-hub serve`), тесты
(SQLite в памяти, API на порту 0 в потоке), первая подсистема — «очередь команд» (Task 5). В ARCHITECTURE.md — те же модули
с зависимостями (`api → queue → commands/mirror/settings → db`; `mcp_server → client`; `importer → mirror/settings`), границы
(«зеркало пишет только `mirror.apply_state` по запросу агента», «хаб не знает пароль Jira», «`queue` не повторяет команды»).
- [ ] **Step 7: Коммит**
```bash
git add src/jira_hub/cli.py tests/test_cli.py README.md CLAUDE.md ARCHITECTURE.md
git commit -m "feat(hub): CLI, README, описание архитектуры
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
```
---
### Task 10: Сквозной прогон хаба с имитацией агента
**Files:**
- Test: `tests/test_e2e_hub.py`
Цель: один тест проходит весь путь «пакет с созданием задачи → выдача → подстановка ключа → состояние → подтверждение» через HTTP, как это сделает настоящий агент.
- [ ] **Step 1: Тест**
`tests/test_e2e_hub.py`:
```python
import threading
import pytest
from jira_hub import api, settings
from jira_hub.client import HubClient
@pytest.fixture
def live(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
tok = settings.tokens(conn)
conn.close()
srv = api.make_server(h, "127.0.0.1", 0)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"
yield HubClient(base, tok["agent"]), HubClient(base, tok["client"])
srv.shutdown()
def test_create_link_sprint_flow(live):
agent, client = live
client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"})
b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23"}},
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
{"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}},
]})
assert b["count"] == 4
seen = []
while True:
cmd = agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"]
if cmd is None:
break
seen.append((cmd["type"], cmd["params"]))
if cmd["type"] == "ensure_issue":
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"key": "A-90"}, "jira_calls": 2})
elif cmd["type"] == "refresh":
agent.post("/agent/state", {"command_id": cmd["id"], "issues": [
{"key": "A-90", "summary": "Новая", "duedate": "2026-10-23", "sprint": "Спринт 2", "labels": [],
"created": "2026-10-11T10:00:00.000+0300"},
{"key": "A-1", "summary": "Старая", "labels": []}],
"links": [{"source_key": "A-90", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]})
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
else:
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"]
assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"]
view = client.get(f"/batches/{b['id']}")
assert view["status"] == "done" and all(c["status"] == "done" for c in view["commands"])
issue = client.get("/issues/A-90")
assert issue["note"]["work_kind"] == "разработка" and issue["links_out"][0]["target_key"] == "A-1"
assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2
```
- [ ] **Step 2: Запустить**
Run: `uv run pytest tests/test_e2e_hub.py -v`
Expected: 1 passed (код уже написан; если падает — ошибка в Tasks 5–6, чинить там)
- [ ] **Step 3: Коммит**
```bash
git add tests/test_e2e_hub.py
git commit -m "test(hub): сквозной прогон очереди через HTTP с имитацией агента
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
```
---
## После merge (вне репозиториев, по чеклисту закрытия)
Не задачи плана — действия в workspace после слияния обоих PR:
1. `awp-ca/CLAUDE.md`, таблица репозиториев: строки `jira-hub/` (aep/jira-hub — хаб моста Jira: зеркало, очередь, API, MCP) и `jira-agent/` (aep/jira-agent — агент на виртуалке).
2. `admin/jira-sync/README.md`: в начале пометка «Заменено мостом jira-hub/jira-agent, CSV заморожены <дата первой сверки>», ссылка на оба репозитория; `admin/README.md` — строка про мост.
3. `~/.claude.json`, проект `awp-ca`: MCP `jira-hub` (блок из README хаба).
4. Ввод в строй: `jira-hub init --config admin/jira-sync/config.example.json` → `jira-hub serve` → на виртуалку `agent.py` + `agent.json` (токен из `jira-hub tokens`) → `python3 agent.py check` → `python3 agent.py` → из MCP `refresh full` → `jira-hub import admin/jira-sync` → сверить `missing_keys`/`missing_links`.
5. Артефакт просмотрщика на claude.ai не переопубликовывать (решение владельца).