Модульная перестройка хаба: ядро + модуль jira (#87) #88

Merged
aep merged 23 commits from feat/87-modules into main 2026-10-23 22:42:15 +03:00
5 changed files with 908 additions and 0 deletions
Showing only changes of commit 808d7b710c - Show all commits

View File

@ -0,0 +1,500 @@
[
{
"description": "Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,\nподстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.",
"inputSchema": {
"properties": {
"assignee": {
"default": "",
"title": "Assignee",
"type": "string"
},
"epic": {
"default": "",
"title": "Epic",
"type": "string"
},
"fact_status": {
"default": "",
"title": "Fact Status",
"type": "string"
},
"label": {
"default": "",
"title": "Label",
"type": "string"
},
"limit": {
"default": 50,
"title": "Limit",
"type": "integer"
},
"neighbor": {
"default": "",
"title": "Neighbor",
"type": "string"
},
"q": {
"default": "",
"title": "Q",
"type": "string"
},
"sprint": {
"default": "",
"title": "Sprint",
"type": "string"
},
"status": {
"default": "",
"title": "Status",
"type": "string"
},
"work_kind": {
"default": "",
"title": "Work Kind",
"type": "string"
}
},
"title": "issuesArguments",
"type": "object"
},
"name": "issues"
},
{
"description": "Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5\nпоследних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком\n(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment.",
"inputSchema": {
"properties": {
"key": {
"title": "Key",
"type": "string"
}
},
"required": [
"key"
],
"title": "issueArguments",
"type": "object"
},
"name": "issue"
},
{
"description": "Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»).",
"inputSchema": {
"properties": {
"comment_id": {
"title": "Comment Id",
"type": "string"
},
"key": {
"title": "Key",
"type": "string"
}
},
"required": [
"key",
"comment_id"
],
"title": "commentArguments",
"type": "object"
},
"name": "comment"
},
{
"description": "Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из\nissue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое.\nХаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600,\nпо умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ\nскачиваются только в Jira.",
"inputSchema": {
"properties": {
"attachment": {
"title": "Attachment",
"type": "string"
},
"key": {
"title": "Key",
"type": "string"
},
"wait_seconds": {
"default": 120,
"title": "Wait Seconds",
"type": "integer"
}
},
"required": [
"key",
"attachment"
],
"title": "attachmentArguments",
"type": "object"
},
"name": "attachment"
},
{
"description": "Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала\nсегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из\nstatus, description, dates, comment, other или all; key — одна задача (без привязки к проекту).",
"inputSchema": {
"properties": {
"hours": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Hours"
},
"key": {
"default": "",
"title": "Key",
"type": "string"
},
"kinds": {
"default": "",
"title": "Kinds",
"type": "string"
},
"since": {
"default": "",
"title": "Since",
"type": "string"
},
"until": {
"default": "",
"title": "Until",
"type": "string"
}
},
"title": "changesArguments",
"type": "object"
},
"name": "changes"
},
{
"description": "Правка служебных заметок задачи (только переданные поля).",
"inputSchema": {
"properties": {
"done_text": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Done Text"
},
"fact_status": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Fact Status"
},
"key": {
"title": "Key",
"type": "string"
},
"owner_note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Owner Note"
},
"rest_text": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Rest Text"
},
"work_kind": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Work Kind"
}
},
"required": [
"key"
],
"title": "notes_setArguments",
"type": "object"
},
"name": "notes_set"
},
{
"description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:<n>), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:<n>), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:<n>), assignee (логин Jira), labels_add (список), labels_remove (список)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.",
"inputSchema": {
"properties": {
"commands": {
"items": {
"additionalProperties": true,
"type": "object"
},
"title": "Commands",
"type": "array"
},
"title": {
"title": "Title",
"type": "string"
}
},
"required": [
"title",
"commands"
],
"title": "batch_createArguments",
"type": "object"
},
"name": "batch_create"
},
{
"description": "Состояние пакета и его команд.",
"inputSchema": {
"properties": {
"id": {
"title": "Id",
"type": "integer"
}
},
"required": [
"id"
],
"title": "batch_statusArguments",
"type": "object"
},
"name": "batch_status"
},
{
"description": "Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.",
"inputSchema": {
"properties": {
"id": {
"title": "Id",
"type": "integer"
},
"timeout_minutes": {
"default": 10,
"title": "Timeout Minutes",
"type": "integer"
}
},
"required": [
"id"
],
"title": "batch_waitArguments",
"type": "object"
},
"name": "batch_wait"
},
{
"description": "Последние пакеты со статусом.",
"inputSchema": {
"properties": {
"limit": {
"default": 10,
"title": "Limit",
"type": "integer"
}
},
"title": "batchesArguments",
"type": "object"
},
"name": "batches"
},
{
"description": "Повторить команду — новый пакет с теми же параметрами.",
"inputSchema": {
"properties": {
"id": {
"title": "Id",
"type": "integer"
}
},
"required": [
"id"
],
"title": "command_retryArguments",
"type": "object"
},
"name": "command_retry"
},
{
"description": "Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —\nв ответе подсказка (POST /commands/N/cancel с force).",
"inputSchema": {
"properties": {
"id": {
"title": "Id",
"type": "integer"
}
},
"required": [
"id"
],
"title": "batch_cancelArguments",
"type": "object"
},
"name": "batch_cancel"
},
{
"description": "Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;\nwait=true — дождаться результата (до 30 мин).",
"inputSchema": {
"properties": {
"full": {
"default": false,
"title": "Full",
"type": "boolean"
},
"keys": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Keys"
},
"wait": {
"default": false,
"title": "Wait",
"type": "boolean"
}
},
"title": "refreshArguments",
"type": "object"
},
"name": "refresh"
},
{
"description": "Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,\nсостояние самообновления и запускатель.",
"inputSchema": {
"properties": {},
"title": "agent_statusArguments",
"type": "object"
},
"name": "agent_status"
},
{
"description": "Журнал обновлений зеркала.",
"inputSchema": {
"properties": {
"limit": {
"default": 10,
"title": "Limit",
"type": "integer"
}
},
"title": "sync_logArguments",
"type": "object"
},
"name": "sync_log"
},
{
"description": "Настройки хаба (все или одна по ключу).",
"inputSchema": {
"properties": {
"key": {
"default": "",
"title": "Key",
"type": "string"
}
},
"title": "settings_getArguments",
"type": "object"
},
"name": "settings_get"
},
{
"description": "Записать настройку: value — JSON. Поднимает settings_version.",
"inputSchema": {
"properties": {
"key": {
"title": "Key",
"type": "string"
},
"value": {
"title": "Value",
"type": "string"
}
},
"required": [
"key",
"value"
],
"title": "settings_setArguments",
"type": "object"
},
"name": "settings_set"
},
{
"description": "Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен.",
"inputSchema": {
"properties": {},
"title": "projectsArguments",
"type": "object"
},
"name": "projects"
},
{
"description": "Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент\n.mcp.json для репозитория проекта.",
"inputSchema": {
"properties": {
"board_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Board Id"
},
"label": {
"title": "Label",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
}
},
"required": [
"label",
"name"
],
"title": "project_createArguments",
"type": "object"
},
"name": "project_create"
}
]

View File

@ -0,0 +1,206 @@
[
[
"GET",
"^/(links)?$",
null,
"h_root"
],
[
"GET",
"^/agent$",
"client",
"h_agent_get"
],
[
"POST",
"^/agent/claim$",
"agent",
"h_claim"
],
[
"GET",
"^/agent/commands/(\\d+)$",
"agent",
"h_agent_command"
],
[
"POST",
"^/agent/commands/(\\d+)/result$",
"agent",
"h_agent_result"
],
[
"POST",
"^/agent/files$",
"agent",
"h_agent_files_post"
],
[
"GET",
"^/agent/files/([^/]+)$",
"agent",
"h_agent_file"
],
[
"POST",
"^/agent/history$",
"agent",
"h_agent_history"
],
[
"GET",
"^/agent/known-keys$",
"agent",
"h_known_keys"
],
[
"GET",
"^/agent/release$",
"agent",
"h_agent_release"
],
[
"GET",
"^/agent/settings$",
"agent",
"h_agent_settings"
],
[
"POST",
"^/agent/state$",
"agent",
"h_agent_state"
],
[
"GET",
"^/app(?:/(.*))?$",
null,
"h_app"
],
[
"GET",
"^/attachments/(\\d+)$",
"client",
"h_attachment_get"
],
[
"GET",
"^/attachments/(\\d+)/content$",
"client",
"h_attachment_content"
],
[
"POST",
"^/attachments/(\\d+)/fetch$",
"client",
"h_attachment_fetch"
],
[
"GET",
"^/batches$",
"client",
"h_batches_get"
],
[
"POST",
"^/batches$",
"client",
"h_batches_post"
],
[
"GET",
"^/batches/(\\d+)$",
"client",
"h_batch_get"
],
[
"POST",
"^/batches/(\\d+)/cancel$",
"client",
"h_batch_cancel"
],
[
"GET",
"^/board$",
"client",
"h_board"
],
[
"GET",
"^/changes$",
"client",
"h_changes"
],
[
"POST",
"^/commands/(\\d+)/cancel$",
"client",
"h_command_cancel"
],
[
"POST",
"^/commands/(\\d+)/retry$",
"client",
"h_command_retry"
],
[
"POST",
"^/files$",
"client",
"h_files_post"
],
[
"GET",
"^/issues$",
"client",
"h_issues"
],
[
"GET",
"^/issues/([^/]+)$",
"client",
"h_issue"
],
[
"PUT",
"^/issues/([^/]+)/note$",
"client",
"h_note_put"
],
[
"GET",
"^/projects$",
"client",
"h_projects_get"
],
[
"POST",
"^/projects$",
"client",
"h_projects_post"
],
[
"POST",
"^/refresh$",
"client",
"h_refresh"
],
[
"GET",
"^/settings$",
"client",
"h_settings_get"
],
[
"PUT",
"^/settings$",
"client",
"h_settings_put"
],
[
"GET",
"^/sync-runs$",
"client",
"h_sync_runs"
]
]

View File

@ -0,0 +1,95 @@
{
"objects": [
[
"index",
"issue_attachment_key",
"CREATE INDEX issue_attachment_key ON issue_attachment(key)"
],
[
"index",
"ix_command_status",
"CREATE INDEX ix_command_status ON command(status, batch_id, seq)"
],
[
"index",
"ix_issue_change_at",
"CREATE INDEX ix_issue_change_at ON issue_change(at)"
],
[
"index",
"ix_issue_change_key",
"CREATE INDEX ix_issue_change_key ON issue_change(key, at)"
],
[
"index",
"ix_issue_link_target",
"CREATE INDEX ix_issue_link_target ON issue_link(target_key)"
],
[
"table",
"agent_status",
"CREATE TABLE agent_status (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_jira_call_at TEXT\n)"
],
[
"table",
"attachment_file",
"CREATE TABLE attachment_file (\n attachment_id TEXT PRIMARY KEY,\n file TEXT NOT NULL,\n size INTEGER NOT NULL,\n fetched_at TEXT NOT NULL\n)"
],
[
"table",
"batch",
"CREATE TABLE batch (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n title TEXT NOT NULL,\n author TEXT NOT NULL,\n created_at TEXT NOT NULL,\n cancelled_at TEXT,\n estimate_seconds INTEGER NOT NULL DEFAULT 0,\n project TEXT\n)"
],
[
"table",
"command",
"CREATE TABLE command (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n batch_id INTEGER NOT NULL REFERENCES batch(id),\n seq INTEGER NOT NULL,\n type TEXT NOT NULL,\n params TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'queued',\n claimed_at TEXT,\n deliveries INTEGER NOT NULL DEFAULT 0,\n finished_at TEXT,\n result TEXT,\n error TEXT,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n pause_seconds INTEGER,\n depends_on INTEGER REFERENCES command(id)\n)"
],
[
"table",
"issue",
"CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)"
],
[
"table",
"issue_attachment",
"CREATE TABLE issue_attachment (\n attachment_id TEXT PRIMARY KEY,\n key TEXT NOT NULL,\n filename TEXT NOT NULL,\n size INTEGER,\n mime_type TEXT,\n author TEXT,\n created TEXT,\n created_at TEXT\n)"
],
[
"table",
"issue_change",
"CREATE TABLE issue_change (\n history_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n key TEXT NOT NULL,\n at TEXT,\n created TEXT NOT NULL,\n author TEXT,\n field TEXT NOT NULL,\n field_id TEXT,\n field_name TEXT NOT NULL,\n from_value TEXT,\n from_string TEXT,\n to_value TEXT,\n to_string TEXT,\n fetched_at TEXT NOT NULL,\n PRIMARY KEY (history_id, seq)\n)"
],
[
"table",
"issue_comment",
"CREATE TABLE issue_comment (\n key TEXT NOT NULL,\n comment_id TEXT NOT NULL,\n author TEXT,\n created TEXT,\n updated TEXT,\n body TEXT NOT NULL DEFAULT '',\n created_at TEXT,\n PRIMARY KEY (key, comment_id)\n)"
],
[
"table",
"issue_link",
"CREATE TABLE issue_link (\n source_key TEXT NOT NULL,\n target_key TEXT NOT NULL,\n link_type TEXT NOT NULL,\n PRIMARY KEY (source_key, target_key, link_type)\n)"
],
[
"table",
"issue_note",
"CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n fact_status TEXT,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)"
],
[
"table",
"project",
"CREATE TABLE project (\n label TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n board_id INTEGER,\n board_type TEXT,\n created_at TEXT NOT NULL\n)"
],
[
"table",
"setting",
"CREATE TABLE setting (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n)"
],
[
"table",
"sync_run",
"CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)"
]
],
"schema_version": 6
}

View File

@ -0,0 +1,63 @@
{
"agent_keys": [
"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"
],
"defaults": {
"auto_refresh_minutes": 60,
"backup_keep": 30,
"closed_statuses": [
"Готово",
"Снято"
],
"comment_marker": "",
"epic_link": {},
"epic_name_field_id": null,
"field_ids": {},
"initialized": false,
"issue_types": {},
"kind_labels": {},
"labels": [],
"link_types": {},
"min_agent_version": 1,
"pacing": {
"ensure_attachment": 30,
"ensure_comment": 30,
"ensure_fields": 120,
"ensure_issue": 120,
"ensure_link": 30,
"ensure_sprint": 30,
"ensure_status": 30,
"fetch_attachment": 2,
"refresh": 2
},
"port": 8790,
"priority_map": {},
"priority_value_key": "name",
"project_key": "AURORA",
"relay_dir": null,
"settings_version": 1,
"status_labels": {},
"transitions_map": {}
},
"protected": [
"schema_version",
"settings_version",
"tokens"
]
}

44
tests/test_baseline.py Normal file
View File

@ -0,0 +1,44 @@
"""Эталоны внешнего поведения (golden): MCP-инструменты, маршруты API, настройки, схема базы.
Перестройка на модули обязана их сохранить. Запись эталонов: BASELINE_WRITE=1 uv run pytest tests/test_baseline.py."""
import asyncio
import json
import os
from pathlib import Path
from jira_hub import api, db, mcp_server, settings
FIXTURES = Path(__file__).parent / "fixtures" / "baseline"
def _check(name, snapshot):
path = FIXTURES / f"{name}.expected.json"
text = json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if os.environ.get("BASELINE_WRITE") == "1":
FIXTURES.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
assert path.read_text(encoding="utf-8") == text
def test_mcp_tools():
tools = asyncio.run(mcp_server.mcp.list_tools())
_check("mcp_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
for t in tools])
def test_routes():
rows = [[method, pattern, scope, fn.__name__] for method, pattern, fn, scope in api.ROUTES]
_check("routes", sorted(rows, key=lambda r: (r[1], r[0])))
def test_settings():
_check("settings", {"defaults": settings.DEFAULTS, "agent_keys": settings.AGENT_KEYS,
"protected": sorted(settings.PROTECTED)})
def test_schema(tmp_path):
conn = db.connect(tmp_path / "hub.sqlite")
rows = conn.execute("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
"ORDER BY type, name").fetchall()
version = db._schema_version(conn)
conn.close()
_check("schema", {"schema_version": version, "objects": [[r[0], r[1], r[2]] for r in rows]})