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