Модуль wiki, агенты модулей, поля проектов (#91) #92
@ -350,35 +350,100 @@ def h_settings_put(hub, conn, m, q, body):
|
||||
return 200, _settings_view(conn)
|
||||
|
||||
|
||||
def project_rows(conn, labels=None) -> list[dict]:
|
||||
"""Проекты плоскими объектами: поля ядра и поля всех модулей (project_view); labels — только эти метки."""
|
||||
rows = projects.list_projects(conn)
|
||||
if labels is not None:
|
||||
rows = [r for r in rows if r["label"] in labels]
|
||||
found = [r["label"] for r in rows]
|
||||
for mod in registry.modules():
|
||||
view = mod.project_view(conn, found) if mod.project_view else {}
|
||||
for r in rows:
|
||||
r.update(view.get(r["label"], {}))
|
||||
return rows
|
||||
|
||||
|
||||
def h_projects_get(hub, conn, m, q, body):
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
return 200, {"projects": projects.list_projects(conn)}
|
||||
return 200, {"projects": project_rows(conn)}
|
||||
|
||||
|
||||
def _author(body) -> str:
|
||||
author = body.get("author")
|
||||
if author is None or author == "":
|
||||
return "api"
|
||||
if not isinstance(author, str):
|
||||
raise ApiError(422, "bad_project", "author — строка")
|
||||
return author
|
||||
|
||||
|
||||
def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object, dict]]:
|
||||
"""Поля модулей из тела: модуль → (модуль, {поле: проверенное значение}). Неизвестное поле — 422; при
|
||||
editable_only неизменяемое поле (в том числе ядра) — 422 «после создания не меняется»."""
|
||||
known = registry.project_fields()
|
||||
out: dict[str, tuple[object, dict]] = {}
|
||||
for key, value in body.items():
|
||||
if key in skip:
|
||||
if editable_only:
|
||||
raise ApiError(422, "bad_project", f"{key} после создания не меняется")
|
||||
continue
|
||||
if key not in known:
|
||||
raise ApiError(422, "bad_project", f"{key}: неизвестное поле")
|
||||
mod, fld = known[key]
|
||||
if editable_only and not fld.editable:
|
||||
raise ApiError(422, "bad_project", f"{key} после создания не меняется")
|
||||
try:
|
||||
value = fld.validate(value)
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_project", f"{key}: {e}")
|
||||
out.setdefault(mod.name, (mod, {}))[1][key] = value
|
||||
return out
|
||||
|
||||
|
||||
def h_projects_post(hub, conn, m, q, body):
|
||||
"""Новый проект: проект и пакеты, которые ставят модули (on_project_created; у jira — «Новый проект <метка>») —
|
||||
в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
|
||||
author = body.get("author")
|
||||
if author is None or author == "":
|
||||
author = "api"
|
||||
elif not isinstance(author, str):
|
||||
raise ApiError(422, "bad_project", "author — строка")
|
||||
batch_id = None
|
||||
"""Новый проект: проект, поля и пакеты модулей (on_project_created; у jira — «Новый проект <метка>») — в одной
|
||||
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).
|
||||
Ответ: project, batch_ids (все пакеты) и batch_id — первый из них (у jira это её пакет, R-2)."""
|
||||
author = _author(body)
|
||||
try:
|
||||
label, name = projects.validate(body.get("label"), body.get("name"))
|
||||
except projects.ProjectError as e:
|
||||
raise ApiError(422, "bad_project", str(e))
|
||||
by_module = _module_fields(body, {"label", "name", "author"}, editable_only=False)
|
||||
batch_ids = []
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
|
||||
project = projects.create(conn, label, name)
|
||||
for mod in registry.modules():
|
||||
batch = mod.on_project_created(conn, project, author) if mod.on_project_created else None
|
||||
if batch is not None and batch_id is None:
|
||||
batch_id = batch["id"]
|
||||
batch = mod.on_project_created(conn, project, by_module.get(mod.name, (mod, {}))[1], author) \
|
||||
if mod.on_project_created else None
|
||||
if batch is not None:
|
||||
batch_ids.append(batch["id"])
|
||||
row = project_rows(conn, [label])[0]
|
||||
except projects.ProjectExists as e:
|
||||
raise ApiError(409, "project_exists", str(e))
|
||||
except projects.ProjectError as e:
|
||||
raise ApiError(422, "bad_project", str(e))
|
||||
except CommandError as e:
|
||||
raise ApiError(422, "bad_batch", str(e))
|
||||
return 201, {"project": project, "batch_id": batch_id}
|
||||
return 201, {"project": row, "batch_id": batch_ids[0] if batch_ids else None, "batch_ids": batch_ids}
|
||||
|
||||
|
||||
def h_project_patch(hub, conn, m, q, body):
|
||||
"""Правка изменяемых полей модулей; пакеты модулей ставятся в той же транзакции."""
|
||||
label = unquote(m.group(1))
|
||||
project = not_found(projects.get(conn, label), f"project {label}")
|
||||
author = _author(body)
|
||||
by_module = _module_fields(body, {"label", "name", "created_at", "author"}, editable_only=True)
|
||||
batch_ids = []
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
for mod, fields in by_module.values():
|
||||
if mod.on_project_updated:
|
||||
batch_ids += [b["id"] for b in mod.on_project_updated(conn, project, fields, author)]
|
||||
row = project_rows(conn, [label])[0]
|
||||
except CommandError as e:
|
||||
raise ApiError(422, "bad_batch", str(e))
|
||||
return 200, {"project": row, "batch_ids": batch_ids}
|
||||
|
||||
|
||||
CORE_ROUTES = [
|
||||
@ -404,6 +469,7 @@ CORE_ROUTES = [
|
||||
("PUT", r"^/settings$", h_settings_put, "client"),
|
||||
("GET", r"^/projects$", h_projects_get, "client"),
|
||||
("POST", r"^/projects$", h_projects_post, "client"),
|
||||
("PATCH", r"^/projects/([^/]+)$", h_project_patch, "client"),
|
||||
]
|
||||
# маршрут ядра и модуля не делят метод + шаблон: пересечение — ошибка старта; ROUTES собирается после проверки
|
||||
registry.check(registry.modules(), CORE_ROUTES, settings.CORE_DEFAULTS)
|
||||
@ -426,6 +492,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_PUT(self):
|
||||
self._dispatch("PUT")
|
||||
|
||||
def do_PATCH(self):
|
||||
self._dispatch("PATCH")
|
||||
|
||||
def log_message(self, fmt, *args): # тише стандартного: без адреса клиента
|
||||
print(f"[{self.log_date_time_string()}] {fmt % args}")
|
||||
|
||||
|
||||
@ -68,3 +68,6 @@ class HubClient:
|
||||
|
||||
def put(self, path, body):
|
||||
return self.call("PUT", path, body)
|
||||
|
||||
def patch(self, path, body):
|
||||
return self.call("PATCH", path, body)
|
||||
|
||||
@ -45,8 +45,6 @@ CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq);
|
||||
CREATE TABLE IF NOT EXISTS project (
|
||||
label TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
board_id INTEGER,
|
||||
board_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_status (
|
||||
@ -64,6 +62,11 @@ def _migrate_projects(conn) -> None:
|
||||
projects.migrate_v3(conn)
|
||||
|
||||
|
||||
def _migrate_jira_project(conn) -> None:
|
||||
from ..modules.jira import projects
|
||||
projects.migrate_v6(conn)
|
||||
|
||||
|
||||
def _fill_comment_utc(conn) -> None:
|
||||
from ..modules.jira import history
|
||||
history.fill_comment_utc(conn)
|
||||
@ -125,7 +128,8 @@ MIGRATIONS = {
|
||||
4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений
|
||||
_fill_comment_utc],
|
||||
5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
||||
6: [_tokens_v7, _agent_status_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
|
||||
6: [_tokens_v7, _agent_status_v7,
|
||||
_migrate_jira_project], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import functools
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from . import registry
|
||||
@ -22,9 +23,13 @@ def unknown_project(label) -> str:
|
||||
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
|
||||
|
||||
|
||||
def mcp_json(label: str) -> dict:
|
||||
return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp",
|
||||
"--project", label]}}}
|
||||
def mcp_json(label: str, module: str = "jira") -> dict:
|
||||
"""Фрагмент .mcp.json для репозитория проекта: у jira прежний (сервер jira-hub), у прочих модулей — сервер
|
||||
jira-hub-<модуль>."""
|
||||
run = ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp"]
|
||||
if module == "jira":
|
||||
return {"mcpServers": {"jira-hub": {"command": "uv", "args": [*run, "--project", label]}}}
|
||||
return {"mcpServers": {f"jira-hub-{module}": {"command": "uv", "args": [*run, module, "--project", label]}}}
|
||||
|
||||
|
||||
def client() -> HubClient:
|
||||
@ -95,40 +100,37 @@ def value(v):
|
||||
return "—" if v in (None, "", []) else str(v)
|
||||
|
||||
|
||||
def fmt_projects(items, current: str | None) -> str:
|
||||
def fmt_projects(items, current: str | None, columns: list[tuple[str, Callable[[dict], str]]]) -> str:
|
||||
"""Таблица проектов: метка, название и колонки модуля (заголовок, функция строки проекта)."""
|
||||
if not items:
|
||||
return "проектов нет"
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
lines = ["метка | название | доска | тип доски | задач"]
|
||||
lines = [" | ".join(["метка", "название", *(title for title, _ in columns)])]
|
||||
for p in items:
|
||||
lines.append(" | ".join([p["label"], p["name"], value(p.get("board_id")), p.get("board_type") or "неизвестен",
|
||||
str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else ""))
|
||||
lines.append(" | ".join([p["label"], p["name"], *(fn(p) for _, fn in columns)])
|
||||
+ (" (текущий)" if p["label"] == current else ""))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@guard
|
||||
def projects() -> str:
|
||||
"""Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен."""
|
||||
return fmt_projects(client().get("/projects")["projects"], PROJECT)
|
||||
|
||||
|
||||
@guard
|
||||
def project_create(label: str, name: str, board_id: int | None = None) -> str: # долг hub-modules: проекты → модуль jira (change wiki)
|
||||
"""Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент
|
||||
.mcp.json для репозитория проекта."""
|
||||
body = {"label": label, "name": name, "author": AUTHOR}
|
||||
if board_id is not None:
|
||||
body["board_id"] = board_id
|
||||
out = client().post("/projects", body)
|
||||
def create_project(label: str, name: str, fields: dict, module: str) -> str:
|
||||
"""POST /projects с автором claude и полями модуля; текст ответа и фрагмент .mcp.json модуля. Сбои хаба —
|
||||
исключения клиента: вызывающий инструмент обёрнут в guard."""
|
||||
out = client().post("/projects", {"label": label, "name": name, "author": AUTHOR, **fields})
|
||||
p = out["project"]
|
||||
return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n"
|
||||
"Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: "
|
||||
"/mcp → reconnect, одобрите сервер проекта.\n"
|
||||
+ json.dumps(mcp_json(p["label"]), ensure_ascii=False, indent=2))
|
||||
+ json.dumps(mcp_json(p["label"], module), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def register_core_tools(mcp) -> None:
|
||||
"""Общие инструменты на сервере модуля: идут последними (порядок списка инструментов — часть контракта)."""
|
||||
def register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Callable[[dict], str]]],
|
||||
project_create: Callable) -> None:
|
||||
"""Общие инструменты на сервере модуля: идут последними (порядок списка инструментов — часть контракта).
|
||||
projects строится из колонок модуля, project_create — инструмент модуля (его параметры — поля проекта модуля)."""
|
||||
@guard
|
||||
def projects() -> str:
|
||||
return fmt_projects(client().get("/projects")["projects"], PROJECT, columns)
|
||||
|
||||
projects.__doc__ = projects_doc
|
||||
mcp.tool()(projects)
|
||||
mcp.tool()(project_create)
|
||||
|
||||
|
||||
@ -32,6 +32,14 @@ class CommandHooks:
|
||||
on_late_result: Callable[[Connection, dict, str, dict | None, int, int], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectField:
|
||||
"""Поле проекта, которое объявляет модуль: ядро проверяет и передаёт его модулю, не зная смысла."""
|
||||
name: str # ключ в теле POST/PATCH и в GET /projects
|
||||
validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение
|
||||
editable: bool = False # можно ли менять после создания (PATCH)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Module:
|
||||
name: str
|
||||
@ -46,7 +54,10 @@ class Module:
|
||||
routes: tuple[Route, ...] = ()
|
||||
tick: tuple[Callable[[Any, Connection], None], ...] = () # (hub, conn)
|
||||
file_refs: Callable[[Connection], set[str]] | None = None # sha файлов, нужных командам
|
||||
on_project_created: Callable[[Connection, dict, str], dict | None] | None = None # project, author → пакет
|
||||
project_fields: tuple[ProjectField, ...] = ()
|
||||
project_view: Callable[[Connection, list[str]], dict[str, dict]] | None = None # метки → {метка: {поле: значение}}
|
||||
on_project_created: Callable[[Connection, dict, dict, str], dict | None] | None = None # проект, свои поля, автор → пакет
|
||||
on_project_updated: Callable[[Connection, dict, dict, str], list[dict]] | None = None # проект, изменённые свои поля, автор → пакеты
|
||||
mcp_server: Callable[[], Any] | None = None # FastMCP модуля
|
||||
cli_register: Callable[[Any], None] | None = None # subparsers
|
||||
cli_run: Callable[[Any], int | None] | None = None # None — не своя подкоманда
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
"""Проекты-метки: таблица project, проверка метки, создание и список."""
|
||||
"""Проекты-метки ядра: таблица project (метка, название, дата), проверка метки, создание и список. Остальные поля
|
||||
проекта хранят и отдают модули (modules.ProjectField)."""
|
||||
import unicodedata
|
||||
|
||||
from . import db
|
||||
|
||||
MAX_LEN = 100
|
||||
_FORBIDDEN = ',"\'()\\'
|
||||
_FIELDS = "label, name, board_id, board_type, created_at" # долг hub-modules: проекты → модуль jira (change wiki)
|
||||
_FIELDS = "label, name, created_at"
|
||||
|
||||
|
||||
class ProjectError(ValueError):
|
||||
"""Ошибка данных проекта; field — label | name | board_id."""
|
||||
"""Ошибка данных проекта; field — label | name | поле модуля."""
|
||||
|
||||
def __init__(self, field: str, reason: str):
|
||||
super().__init__(f"{field}: {reason}")
|
||||
@ -31,8 +32,8 @@ def label_error(label) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def validate(label, name, board_id) -> tuple[str, str, int | None]:
|
||||
"""Проверить поля проекта; вернуть (метка, название без пробелов по краям, доска)."""
|
||||
def validate(label, name) -> tuple[str, str]:
|
||||
"""Проверить поля ядра; вернуть (метка, название без пробелов по краям)."""
|
||||
error = label_error(label)
|
||||
if error:
|
||||
raise ProjectError("label", error)
|
||||
@ -40,10 +41,7 @@ def validate(label, name, board_id) -> tuple[str, str, int | None]:
|
||||
raise ProjectError("name", "ожидается непустая строка")
|
||||
if len(name.strip()) > MAX_LEN:
|
||||
raise ProjectError("name", f"длиннее {MAX_LEN} символов")
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
if board_id is not None and (not isinstance(board_id, int) or isinstance(board_id, bool) or board_id <= 0):
|
||||
raise ProjectError("board_id", "ожидается целое > 0 или null")
|
||||
return label, name.strip(), board_id
|
||||
return label, name.strip()
|
||||
|
||||
|
||||
def get(conn, label: str) -> dict | None:
|
||||
@ -51,30 +49,18 @@ def get(conn, label: str) -> dict | None:
|
||||
return None if row is None else dict(row)
|
||||
|
||||
|
||||
def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
|
||||
label, name, board_id = validate(label, name, board_id)
|
||||
def create(conn, label: str, name: str) -> dict:
|
||||
label, name = validate(label, name)
|
||||
with db.transaction(conn):
|
||||
if label.casefold() in {x.casefold() for x in labels(conn)}:
|
||||
raise ProjectExists(label)
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)",
|
||||
(label, name, board_id, db.now()))
|
||||
conn.execute("INSERT INTO project(label, name, created_at) VALUES (?, ?, ?)", (label, name, db.now()))
|
||||
return get(conn, label)
|
||||
|
||||
|
||||
def list_projects(conn) -> list[dict]:
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
rows = conn.execute(
|
||||
f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
|
||||
"(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count "
|
||||
"FROM project ORDER BY created_at, label").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def light_list(conn) -> list[dict]:
|
||||
"""Проекты без issues_count (label, board_id, board_type), порядок как у list_projects."""
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
return [dict(r) for r in conn.execute("SELECT label, board_id, board_type FROM project ORDER BY created_at, label")]
|
||||
"""Проекты с полями ядра по created_at, затем label."""
|
||||
return [dict(r) for r in conn.execute(f"SELECT {_FIELDS} FROM project ORDER BY created_at, label")]
|
||||
|
||||
|
||||
def labels(conn) -> list[str]:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""Реестр модулей хаба. Список собирается лениво: ядро не импортирует модули при импорте."""
|
||||
from .modules import Module
|
||||
from .modules import Module, ProjectField
|
||||
|
||||
_cache: list[Module] | None = None
|
||||
|
||||
@ -24,6 +24,14 @@ def agent_modules() -> list[Module]:
|
||||
return [m for m in modules() if m.commands]
|
||||
|
||||
|
||||
CORE_PROJECT_FIELDS = ("label", "name", "created_at", "author") # author — служебный ключ тела POST/PATCH
|
||||
|
||||
|
||||
def project_fields() -> dict[str, tuple[Module, ProjectField]]:
|
||||
"""Поля проектов всех модулей: имя → (модуль, поле)."""
|
||||
return {f.name: (m, f) for m in modules() for f in m.project_fields}
|
||||
|
||||
|
||||
def module(name: str) -> Module | None:
|
||||
return next((m for m in modules() if m.name == name), None)
|
||||
|
||||
@ -38,6 +46,7 @@ def check(mods: list[Module], core_routes, core_settings: dict) -> None:
|
||||
types: dict[str, str] = {}
|
||||
keys: dict[str, str] = {key: "ядро" for key in core_settings}
|
||||
routes: dict[tuple[str, str], str] = {(r[0], r[1]): "ядро" for r in core_routes}
|
||||
fields: dict[str, str] = {name: "ядро" for name in CORE_PROJECT_FIELDS}
|
||||
for m in mods:
|
||||
for ctype in sorted(m.commands.types) if m.commands else ():
|
||||
if ctype in types:
|
||||
@ -47,6 +56,10 @@ def check(mods: list[Module], core_routes, core_settings: dict) -> None:
|
||||
if key in keys:
|
||||
raise RuntimeError(f"ключ настроек {key}: {keys[key]} и модуль {m.name}")
|
||||
keys[key] = m.name
|
||||
for f in m.project_fields:
|
||||
if f.name in fields:
|
||||
raise RuntimeError(f"поле проекта {f.name}: {fields[f.name]} и {m.name}")
|
||||
fields[f.name] = m.name
|
||||
for r in m.routes:
|
||||
if (r[0], r[1]) in routes:
|
||||
raise RuntimeError(f"маршрут {r[0]} {r[1]}: {routes[(r[0], r[1])]} и модуль {m.name}")
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений."""
|
||||
from ...core.modules import Module
|
||||
from . import api, cli, hooks, refresh, schema, settings
|
||||
from . import api, cli, hooks, project_fields, refresh, schema, settings
|
||||
|
||||
|
||||
def mcp_server():
|
||||
@ -22,6 +22,8 @@ MODULE = Module(
|
||||
file_refs=hooks.file_refs,
|
||||
routes=api.ROUTES,
|
||||
tick=(refresh.auto_refresh_tick, api.downloads_tick),
|
||||
project_fields=project_fields.FIELDS,
|
||||
project_view=project_fields.view,
|
||||
on_project_created=api.on_project_created,
|
||||
mcp_server=mcp_server,
|
||||
cli_register=cli.cli_register,
|
||||
|
||||
@ -205,7 +205,9 @@ def downloads_tick(hub, conn, now=None) -> None:
|
||||
downloads_tick.label = "уборка копий вложений"
|
||||
|
||||
|
||||
def on_project_created(conn, project, author) -> dict | None:
|
||||
"""Проект создан (в той же транзакции): пакет «Новый проект <метка>» — один refresh по базовой JQL."""
|
||||
def on_project_created(conn, project, fields, author) -> dict | None:
|
||||
"""Проект создан (в той же транзакции): доска в jira_project и пакет «Новый проект <метка>» — один refresh по
|
||||
базовой JQL."""
|
||||
conn.execute("INSERT INTO jira_project(label, board_id) VALUES (?, ?)", (project["label"], fields.get("board_id")))
|
||||
return queue.create_batch(conn, f"Новый проект {project['label']}", author,
|
||||
[{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||||
|
||||
@ -55,7 +55,7 @@ def _assign_boards(conn, cmds) -> None:
|
||||
"""Доска проекта задачи в params ensure_sprint (design #7): метки временного ключа — из его ensure_issue в пакете,
|
||||
иначе из зеркала; 422 — задача вне проектов, разные доски, нет доски, канбан, тип ещё не известен."""
|
||||
temp_labels = {c["params"]["temp_key"]: c["params"].get("labels") or [] for c in cmds if c["type"] == "ensure_issue"}
|
||||
found = projects.light_list(conn)
|
||||
found = jira_projects.light_list(conn)
|
||||
for i, c in enumerate(cmds, 1):
|
||||
if c["type"] != "ensure_sprint":
|
||||
continue
|
||||
|
||||
@ -557,4 +557,19 @@ def settings_set(key: str, value: str) -> str:
|
||||
return f"settings_version = {out['settings_version']}"
|
||||
|
||||
|
||||
core.register_core_tools(mcp)
|
||||
@core.guard
|
||||
def project_create(label: str, name: str, board_id: int | None = None) -> str:
|
||||
"""Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент
|
||||
.mcp.json для репозитория проекта."""
|
||||
return core.create_project(label, name, {} if board_id is None else {"board_id": board_id}, "jira")
|
||||
|
||||
|
||||
PROJECT_COLUMNS = [("доска", lambda p: core.value(p.get("board_id"))),
|
||||
("тип доски", lambda p: p.get("board_type") or "неизвестен"),
|
||||
("задач", lambda p: str(p.get("issues_count", 0)))]
|
||||
|
||||
core.register_core_tools(
|
||||
mcp,
|
||||
projects_doc="Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен.",
|
||||
columns=PROJECT_COLUMNS,
|
||||
project_create=project_create)
|
||||
|
||||
25
src/jira_hub/modules/jira/project_fields.py
Normal file
25
src/jira_hub/modules/jira/project_fields.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Поля проекта модуля jira: доска (board_id, board_type) и число задач зеркала."""
|
||||
from ...core.modules import ProjectField
|
||||
|
||||
|
||||
def validate_board(value):
|
||||
"""board_id: целое > 0 или null."""
|
||||
if value is not None and (not isinstance(value, int) or isinstance(value, bool) or value <= 0):
|
||||
raise ValueError("ожидается целое > 0 или null")
|
||||
return value
|
||||
|
||||
|
||||
FIELDS = (ProjectField("board_id", validate_board, editable=False),)
|
||||
|
||||
|
||||
def view(conn, labels: list[str]) -> dict[str, dict]:
|
||||
"""Значения полей для списка проектов: метка → board_id, board_type, issues_count."""
|
||||
if not labels:
|
||||
return {}
|
||||
marks = ", ".join("?" * len(labels))
|
||||
rows = conn.execute(
|
||||
"SELECT p.label, jp.board_id, jp.board_type, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
|
||||
"(SELECT 1 FROM json_each(i.labels) WHERE value = p.label)) AS issues_count "
|
||||
f"FROM project p LEFT JOIN jira_project jp ON jp.label = p.label WHERE p.label IN ({marks})", labels)
|
||||
return {r["label"]: {"board_id": r["board_id"], "board_type": r["board_type"], "issues_count": r["issues_count"]}
|
||||
for r in rows}
|
||||
@ -2,8 +2,11 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ...core import db
|
||||
from ...core import projects as core_projects
|
||||
from ...core import settings
|
||||
from ...core.projects import ProjectError, label_error, create, labels, validate
|
||||
from ...core.projects import ProjectError, label_error, labels
|
||||
from . import project_fields
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -21,12 +24,50 @@ def base_jql(conn) -> str | None:
|
||||
return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})"
|
||||
|
||||
|
||||
def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
|
||||
"""Проект с доской: проект ядра и строка jira_project (прямой путь без POST /projects: init, миграция, тесты).
|
||||
Неверная доска — ProjectError("board_id", …) до записи."""
|
||||
try:
|
||||
board_id = project_fields.validate_board(board_id)
|
||||
except ValueError as e:
|
||||
raise ProjectError("board_id", str(e))
|
||||
with db.transaction(conn):
|
||||
project = core_projects.create(conn, label, name)
|
||||
conn.execute("INSERT INTO jira_project(label, board_id) VALUES (?, ?)", (project["label"], board_id))
|
||||
return project
|
||||
|
||||
|
||||
def board_of(conn, label: str) -> int | None:
|
||||
row = conn.execute("SELECT board_id FROM jira_project WHERE label = ?", (label,)).fetchone()
|
||||
return None if row is None else row["board_id"]
|
||||
|
||||
|
||||
def light_list(conn) -> list[dict]:
|
||||
"""Проекты с доской (label, board_id, board_type) в порядке списка проектов."""
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT p.label, jp.board_id, jp.board_type FROM project p LEFT JOIN jira_project jp ON jp.label = p.label "
|
||||
"ORDER BY p.created_at, p.label")]
|
||||
|
||||
|
||||
def migrate_v6(conn) -> None:
|
||||
"""Шаг миграции 6 → 7: board_id и board_type уходят из project в jira_project. База, собранная из схемы v7
|
||||
(колонок уже нет), не трогается."""
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(project)")}
|
||||
if "board_id" not in cols:
|
||||
return
|
||||
conn.execute("INSERT OR IGNORE INTO jira_project(label, board_id, board_type) "
|
||||
"SELECT label, board_id, board_type FROM project")
|
||||
conn.execute("ALTER TABLE project DROP COLUMN board_id")
|
||||
conn.execute("ALTER TABLE project DROP COLUMN board_type")
|
||||
|
||||
|
||||
def boards(conn) -> list[dict]:
|
||||
"""Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного
|
||||
проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный."""
|
||||
out: dict[int, str | None] = {}
|
||||
unknown: set[int] = set()
|
||||
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
|
||||
for r in conn.execute("SELECT jp.board_id, jp.board_type FROM jira_project jp JOIN project p ON p.label = jp.label "
|
||||
"WHERE jp.board_id IS NOT NULL ORDER BY p.created_at, p.label"):
|
||||
out.setdefault(r["board_id"], r["board_type"])
|
||||
if r["board_type"] is None:
|
||||
unknown.add(r["board_id"])
|
||||
@ -45,7 +86,7 @@ def record_board_types(conn, found) -> int:
|
||||
board = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
|
||||
updated += conn.execute("UPDATE jira_project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
|
||||
return updated
|
||||
|
||||
|
||||
@ -73,8 +114,8 @@ def ensure_legacy(conn, board_id: int | None) -> bool:
|
||||
if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}:
|
||||
return False
|
||||
try:
|
||||
validate(LEGACY_LABEL, LEGACY_NAME, board_id)
|
||||
except ProjectError:
|
||||
project_fields.validate_board(board_id)
|
||||
except ValueError:
|
||||
board_id = None
|
||||
create(conn, LEGACY_LABEL, LEGACY_NAME, board_id)
|
||||
return True
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Схема базы модуля jira: зеркало задач, журнал изменений, вложения, заметки, прогоны обновления."""
|
||||
"""Схема базы модуля jira: зеркало задач, журнал изменений, вложения, заметки, прогоны обновления, доски проектов."""
|
||||
|
||||
SQL = """
|
||||
CREATE TABLE IF NOT EXISTS issue (
|
||||
@ -98,4 +98,9 @@ CREATE TABLE IF NOT EXISTS sync_run (
|
||||
command_id INTEGER,
|
||||
history_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS jira_project (
|
||||
label TEXT PRIMARY KEY REFERENCES project(label),
|
||||
board_id INTEGER,
|
||||
board_type TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Настройки модуля jira: умолчания, ключи для агента, проверка значений, перенос из config.example.json."""
|
||||
from ...core import projects as core_projects
|
||||
from ...core import settings as core_settings
|
||||
from . import projects
|
||||
|
||||
@ -118,6 +117,6 @@ def on_init(conn, cfg: dict, force: bool) -> list[str]:
|
||||
"""init --config: настройки Jira и проект AWPCA; строки для вывода."""
|
||||
lines = ["перенесено: " + ", ".join(init_from_config(conn, cfg, force=force))]
|
||||
if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")):
|
||||
board = core_projects.get(conn, projects.LEGACY_LABEL)["board_id"]
|
||||
board = projects.board_of(conn, projects.LEGACY_LABEL)
|
||||
lines.append(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}")
|
||||
return lines
|
||||
|
||||
@ -9,6 +9,7 @@ from jira_hub.core import api, db, projects, registry, settings
|
||||
from jira_hub.core.client import HubClient
|
||||
from jira_hub.core.modules import CommandHooks, Module
|
||||
from jira_hub.modules.jira import MODULE as JIRA_MODULE
|
||||
from jira_hub.modules.jira import project_fields
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
|
||||
|
||||
@ -33,10 +34,10 @@ def add_project():
|
||||
def make(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum"):
|
||||
if name is None:
|
||||
name = jira_projects.LEGACY_NAME if label == jira_projects.LEGACY_LABEL else label
|
||||
projects.create(conn, label, name, board_id)
|
||||
jira_projects.create(conn, label, name, board_id) # проект ядра и доска в jira_project
|
||||
if board_id is not None and board_type is not None:
|
||||
jira_projects.record_board_types(conn, {str(board_id): board_type})
|
||||
return projects.get(conn, label)
|
||||
return projects.get(conn, label) | project_fields.view(conn, [label])[label]
|
||||
return make
|
||||
|
||||
|
||||
|
||||
6
tests/fixtures/baseline/routes.expected.json
vendored
6
tests/fixtures/baseline/routes.expected.json
vendored
@ -185,6 +185,12 @@
|
||||
"client",
|
||||
"h_projects_post"
|
||||
],
|
||||
[
|
||||
"PATCH",
|
||||
"^/projects/([^/]+)$",
|
||||
"client",
|
||||
"h_project_patch"
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"^/refresh$",
|
||||
|
||||
7
tests/fixtures/baseline/schema.expected.json
vendored
7
tests/fixtures/baseline/schema.expected.json
vendored
@ -75,10 +75,15 @@
|
||||
"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",
|
||||
"jira_project",
|
||||
"CREATE TABLE jira_project (\n label TEXT PRIMARY KEY REFERENCES project(label),\n board_id INTEGER,\n board_type 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)"
|
||||
"CREATE TABLE project (\n label TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n created_at TEXT NOT NULL\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
|
||||
@ -38,7 +38,7 @@ def test_v6_upgrade_keeps_settings_and_projects(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
assert settings.get(conn, "min_agent_version") == 1
|
||||
rows = conn.execute("SELECT label, board_id, board_type FROM project ORDER BY label").fetchall()
|
||||
rows = conn.execute("SELECT label, board_id, board_type FROM jira_project ORDER BY label").fetchall()
|
||||
assert [tuple(r) for r in rows] == [("AWPCA", 4216, "scrum"), ("sfm_kedr", None, None)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@ -571,7 +571,7 @@ def test_settings_put_empty_body_returns_settings_unchanged(hub):
|
||||
assert c.put("/settings", {}) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["DELETE", "PATCH"])
|
||||
@pytest.mark.parametrize("method", ["DELETE", "OPTIONS"])
|
||||
def test_other_methods_are_501_not_json(hub, method):
|
||||
"""M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется."""
|
||||
import http.client
|
||||
|
||||
@ -2,7 +2,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from jira_hub.core import cli, paths, projects, release, settings
|
||||
from jira_hub.core import api, cli, paths, release, settings
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||
|
||||
@ -15,12 +15,12 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
|
||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
|
||||
assert "уже инициализирован" in capsys.readouterr().err
|
||||
conn = open_db(paths.db_path())
|
||||
assert [(x["label"], x["name"], x["board_id"]) for x in projects.list_projects(conn)] == [("AWPCA", "АРМ КА", 4216)]
|
||||
assert [(x["label"], x["name"], x["board_id"]) for x in api.project_rows(conn)] == [("AWPCA", "АРМ КА", 4216)]
|
||||
assert settings.get(conn, "board_id") is None
|
||||
assert "проект AWPCA «АРМ КА», доска 4216" in out
|
||||
assert cli.main(["init", "--config", str(FIXTURE), "--force"]) == 0
|
||||
capsys.readouterr()
|
||||
assert [(x["label"], x["board_id"]) for x in projects.list_projects(conn)] == [("AWPCA", 4216)]
|
||||
assert [(x["label"], x["board_id"]) for x in api.project_rows(conn)] == [("AWPCA", 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
|
||||
|
||||
@ -7,7 +7,7 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import backup, db, paths, projects, queue, settings
|
||||
from jira_hub.core import api, backup, db, paths, projects, queue, settings
|
||||
from jira_hub.modules.jira import history
|
||||
|
||||
|
||||
@ -280,7 +280,7 @@ def test_migrates_v3_creates_awpca(tmp_path):
|
||||
_v3_database(path)
|
||||
conn = db.connect(path)
|
||||
new = 'project = AURORA AND labels in ("AWPCA")'
|
||||
assert projects.list_projects(conn)[0] | {"created_at": None, "issues_count": None} == {
|
||||
assert api.project_rows(conn)[0] | {"created_at": None, "issues_count": None} == {
|
||||
"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None}
|
||||
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте
|
||||
assert settings.get(conn, "board_id") == 4216
|
||||
@ -299,7 +299,7 @@ def test_migrates_v3_creates_awpca(tmp_path):
|
||||
def test_migrates_v3_two_labels_board_to_nobody(tmp_path, caplog):
|
||||
_v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels in (AWPCA, STOCKS_DRRK)")
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
got = {p["label"]: (p["name"], p["board_id"]) for p in projects.list_projects(conn)}
|
||||
got = {p["label"]: (p["name"], p["board_id"]) for p in api.project_rows(conn)}
|
||||
assert got == {"AWPCA": ("АРМ КА", None), "STOCKS_DRRK": ("STOCKS_DRRK", None)}
|
||||
assert "4216" in caplog.text
|
||||
conn.close()
|
||||
|
||||
@ -427,6 +427,11 @@ def test_batch_create_description_mentions_path():
|
||||
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
|
||||
|
||||
|
||||
def _projects():
|
||||
"""Инструмент projects сервера jira: его строит register_core_tools, в модуле функции нет."""
|
||||
return jmcp.mcp._tool_manager.get_tool("projects").fn()
|
||||
|
||||
|
||||
def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта»
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||||
calls = []
|
||||
@ -438,7 +443,7 @@ def test_unbound_session(live, monkeypatch): # сце
|
||||
assert calls == []
|
||||
for out in (jmcp.batches(), jmcp.sync_log(), jmcp.settings_get(), asyncio.run(jmcp.batch_wait(1))):
|
||||
assert out != core_mcp.NOT_BOUND # не зависящие от проекта — работают без привязки
|
||||
assert "AWPCA" in core_mcp.projects()
|
||||
assert "AWPCA" in _projects()
|
||||
assert jmcp.agent_status().startswith("агент ") # служебные инструменты работают без привязки
|
||||
|
||||
|
||||
@ -447,7 +452,7 @@ def test_unknown_bound_project(live, monkeypatch): # сце
|
||||
assert jmcp.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json"
|
||||
assert jmcp.agent_status().startswith("проект STOKS не заведён")
|
||||
assert asyncio.run(jmcp.batch_wait(1)).startswith("проект STOKS не заведён")
|
||||
assert "AWPCA" in core_mcp.projects()
|
||||
assert "AWPCA" in _projects()
|
||||
|
||||
|
||||
def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён»
|
||||
@ -456,7 +461,7 @@ def test_bound_offline(monkeypatch): # Review
|
||||
assert jmcp.issues() == core_mcp.OFFLINE
|
||||
assert jmcp.agent_status() == core_mcp.OFFLINE
|
||||
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
|
||||
assert core_mcp.projects() == core_mcp.OFFLINE
|
||||
assert _projects() == core_mcp.OFFLINE
|
||||
|
||||
|
||||
def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки»
|
||||
@ -495,14 +500,14 @@ def test_bound_batches_and_retry(live, monkeypatch):
|
||||
|
||||
def test_project_create(live, monkeypatch): # сценарий «Проект из чата»
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||||
out = core_mcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
|
||||
out = jmcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
|
||||
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
|
||||
frag = json.loads(out[out.index("{"):])
|
||||
assert frag == core_mcp.mcp_json("STOCKS_DRRK")
|
||||
assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
|
||||
assert live["client"].get("/batches")["batches"][0]["author"] == "claude"
|
||||
assert core_mcp.project_create("awpca", "x").startswith("ошибка хаба 409")
|
||||
assert core_mcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
|
||||
assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409")
|
||||
assert jmcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
|
||||
|
||||
|
||||
def test_hub_root_is_repo():
|
||||
@ -513,12 +518,12 @@ def test_hub_root_is_repo():
|
||||
def test_fmt_projects():
|
||||
items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190},
|
||||
{"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}]
|
||||
out = core_mcp.fmt_projects(items, "AWPCA").splitlines()
|
||||
out = core_mcp.fmt_projects(items, "AWPCA", jmcp.PROJECT_COLUMNS).splitlines()
|
||||
assert out[0] == "метка | название | доска | тип доски | задач"
|
||||
assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)"
|
||||
assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0"
|
||||
assert core_mcp.fmt_projects([], None) == "проектов нет"
|
||||
assert "(текущий)" not in core_mcp.fmt_projects(items, None)
|
||||
assert core_mcp.fmt_projects([], None, jmcp.PROJECT_COLUMNS) == "проектов нет"
|
||||
assert "(текущий)" not in core_mcp.fmt_projects(items, None, jmcp.PROJECT_COLUMNS)
|
||||
|
||||
|
||||
def test_run_binds_project(monkeypatch):
|
||||
|
||||
169
tests/test_project_fields.py
Normal file
169
tests/test_project_fields.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Поля проекта от модулей; доски — в модуле jira (wiki-module, решение 8, спеки modules/data-model/hub-api)."""
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, mcp as core_mcp, registry
|
||||
from jira_hub.core.client import HubError
|
||||
from jira_hub.core.modules import Module, ProjectField
|
||||
from jira_hub.modules.jira import mirror
|
||||
from v6db import make_v6_db
|
||||
|
||||
|
||||
def _ok(v):
|
||||
return v
|
||||
|
||||
|
||||
def test_v6_boards_moved(tmp_path):
|
||||
make_v6_db(tmp_path / "hub.sqlite")
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(project)")}
|
||||
assert cols == {"label", "name", "created_at"}
|
||||
rows = conn.execute("SELECT label, board_id, board_type FROM jira_project ORDER BY label").fetchall()
|
||||
assert [tuple(r) for r in rows] == [("AWPCA", 4216, "scrum"), ("sfm_kedr", None, None)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_projects_get_shape(hub_server, add_project):
|
||||
conn = hub_server["hub"].conn()
|
||||
try:
|
||||
add_project(conn)
|
||||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
|
||||
finally:
|
||||
conn.close()
|
||||
got = hub_server["client"].get("/projects")["projects"]
|
||||
assert got == [{"label": "AWPCA", "name": "АРМ КА", "created_at": got[0]["created_at"],
|
||||
"board_id": 4216, "board_type": "scrum", "issues_count": 1}]
|
||||
|
||||
|
||||
def test_post_unknown_field(hub_server):
|
||||
c = hub_server["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.post("/projects", {"label": "X", "name": "X", "board": 5})
|
||||
assert e.value.status == 422 and e.value.error == "bad_project" and "board" in e.value.detail
|
||||
assert c.get("/projects")["projects"] == []
|
||||
|
||||
|
||||
def test_post_returns_batch_ids(hub_server):
|
||||
out = hub_server["client"].post("/projects", {"label": "X", "name": "X", "board_id": 7})
|
||||
assert out["batch_ids"] == [out["batch_id"]] and out["project"]["board_id"] == 7
|
||||
|
||||
|
||||
def test_patch_board_forbidden(hub_server, add_project):
|
||||
conn = hub_server["hub"].conn()
|
||||
try:
|
||||
add_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
c = hub_server["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.patch("/projects/AWPCA", {"board_id": 1})
|
||||
assert e.value.status == 422 and e.value.error == "bad_project"
|
||||
assert "board_id после создания не меняется" in e.value.detail
|
||||
assert c.get("/projects")["projects"][0]["board_id"] == 4216
|
||||
|
||||
|
||||
def test_patch_unknown_project(hub_server):
|
||||
c = hub_server["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.patch("/projects/NOPE", {})
|
||||
assert e.value.status == 404
|
||||
|
||||
|
||||
def test_patch_empty_ok(hub_server, add_project):
|
||||
conn = hub_server["hub"].conn()
|
||||
try:
|
||||
add_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
c = hub_server["client"]
|
||||
out = c.patch("/projects/AWPCA", {})
|
||||
assert out["batch_ids"] == [] and out["project"]["label"] == "AWPCA"
|
||||
|
||||
|
||||
def test_field_conflict():
|
||||
a = Module(name="a", project_fields=(ProjectField("root", _ok),))
|
||||
b = Module(name="b", project_fields=(ProjectField("root", _ok),))
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
registry.check([a, b], [], {})
|
||||
text = str(e.value)
|
||||
assert "root" in text and "a" in text and "b" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["label", "name", "created_at", "author"])
|
||||
def test_field_clashes_with_core(name):
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
registry.check([Module(name="a", project_fields=(ProjectField(name, _ok),))], [], {})
|
||||
assert name in str(e.value)
|
||||
|
||||
|
||||
def test_mcp_json_per_module():
|
||||
first = core_mcp.mcp_json("AWPCA")["mcpServers"]
|
||||
assert list(first) == ["jira-hub"] and first["jira-hub"]["args"][-3:] == ["mcp", "--project", "AWPCA"]
|
||||
other = core_mcp.mcp_json("AWPCA", "wiki")["mcpServers"]
|
||||
assert list(other) == ["jira-hub-wiki"]
|
||||
assert other["jira-hub-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"]
|
||||
|
||||
|
||||
def test_fmt_projects_columns():
|
||||
items = [{"label": "A", "name": "a", "x": 1}, {"label": "B", "name": "b", "x": None}]
|
||||
text = core_mcp.fmt_projects(items, "B", [("икс", lambda p: core_mcp.value(p.get("x")))])
|
||||
assert text == "метка | название | икс\nA | a | 1\nB | b | — (текущий)"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_modules(monkeypatch, open_db):
|
||||
"""Реестр [jira, stub]: stub объявляет изменяемое поле root, хранит его в setting и ставит пакеты-пустышки."""
|
||||
import json
|
||||
import re
|
||||
from jira_hub.core import api, queue
|
||||
from jira_hub.core.http import ApiError
|
||||
from jira_hub.modules.jira import MODULE as JIRA
|
||||
|
||||
def check_root(v):
|
||||
if not isinstance(v, str) or not v:
|
||||
raise ValueError("ожидается непустая строка")
|
||||
return v.upper()
|
||||
|
||||
def created(conn, project, fields, author):
|
||||
if "root" in fields:
|
||||
conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)",
|
||||
(f"root:{project['label']}", json.dumps(fields["root"])))
|
||||
|
||||
def updated(conn, project, fields, author):
|
||||
created(conn, project, fields, author)
|
||||
return [queue.create_batch(conn, "stub", author, [{"type": "refresh", "params": {"keys": ["A-1"]}}])]
|
||||
|
||||
def view(conn, labels):
|
||||
out = {}
|
||||
for label in labels:
|
||||
row = conn.execute("SELECT value FROM setting WHERE key = ?", (f"root:{label}",)).fetchone()
|
||||
out[label] = {"root": json.loads(row["value"]) if row else None}
|
||||
return out
|
||||
|
||||
stub = Module(name="stub", project_fields=(ProjectField("root", check_root, editable=True),),
|
||||
project_view=view, on_project_created=created, on_project_updated=updated)
|
||||
monkeypatch.setattr(registry, "_cache", [JIRA, stub])
|
||||
conn = open_db()
|
||||
return api, ApiError, conn, re
|
||||
|
||||
|
||||
def test_module_fields_post_and_patch(two_modules):
|
||||
api, ApiError, conn, re = two_modules
|
||||
code, out = api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "board_id": 3, "root": "doc-1"})
|
||||
assert code == 201 and out["project"]["root"] == "DOC-1" and out["project"]["board_id"] == 3
|
||||
assert out["batch_ids"] == [out["batch_id"]] # пакет есть только у jira
|
||||
code, out = api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": "doc-2"})
|
||||
assert code == 200 and out["project"]["root"] == "DOC-2" and len(out["batch_ids"]) == 1
|
||||
with pytest.raises(ApiError) as e:
|
||||
api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": ""})
|
||||
assert e.value.status == 422 and e.value.detail.startswith("root")
|
||||
assert api.project_rows(conn)[0]["root"] == "DOC-2"
|
||||
|
||||
|
||||
def test_module_field_error_writes_nothing(two_modules):
|
||||
api, ApiError, conn, re = two_modules
|
||||
with pytest.raises(ApiError) as e:
|
||||
api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "root": 5})
|
||||
assert e.value.status == 422 and e.value.detail.startswith("root")
|
||||
assert api.project_rows(conn) == []
|
||||
@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import projects, settings
|
||||
from jira_hub.core import api, projects, settings
|
||||
from jira_hub.modules.jira import mirror
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
|
||||
@ -13,26 +13,26 @@ def conn(open_db):
|
||||
|
||||
|
||||
def test_boards_type_unknown_if_any_project_unknown(conn): # I-1: новый проект на известной доске
|
||||
projects.create(conn, "AWPCA", "a", 4216)
|
||||
jira_projects.create(conn, "AWPCA", "a", 4216)
|
||||
jira_projects.record_board_types(conn, {"4216": "scrum"})
|
||||
projects.create(conn, "NEW", "n", 4216)
|
||||
assert projects.get(conn, "NEW")["board_type"] is None
|
||||
jira_projects.create(conn, "NEW", "n", 4216)
|
||||
assert api.project_rows(conn, ["NEW"])[0]["board_type"] is None
|
||||
assert jira_projects.boards(conn) == [{"id": 4216, "type": None}]
|
||||
jira_projects.record_board_types(conn, {"4216": "scrum"})
|
||||
assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}]
|
||||
assert [p["board_type"] for p in projects.list_projects(conn)] == ["scrum", "scrum"]
|
||||
assert [p["board_type"] for p in api.project_rows(conn)] == ["scrum", "scrum"]
|
||||
|
||||
|
||||
def test_boards_diverged_types_first_by_order(conn):
|
||||
projects.create(conn, "A", "a", 7); projects.create(conn, "B", "b", 7)
|
||||
conn.execute("UPDATE project SET board_type = 'kanban' WHERE label = 'B'")
|
||||
conn.execute("UPDATE project SET board_type = 'scrum' WHERE label = 'A'")
|
||||
jira_projects.create(conn, "A", "a", 7); jira_projects.create(conn, "B", "b", 7)
|
||||
conn.execute("UPDATE jira_project SET board_type = 'kanban' WHERE label = 'B'")
|
||||
conn.execute("UPDATE jira_project SET board_type = 'scrum' WHERE label = 'A'")
|
||||
assert jira_projects.boards(conn) == [{"id": 7, "type": "scrum"}]
|
||||
|
||||
|
||||
def test_light_list_same_order_no_count(conn):
|
||||
projects.create(conn, "B", "b", 1); projects.create(conn, "A", "a")
|
||||
got = projects.light_list(conn)
|
||||
jira_projects.create(conn, "B", "b", 1); jira_projects.create(conn, "A", "a")
|
||||
got = jira_projects.light_list(conn)
|
||||
assert [p["label"] for p in got] == [p["label"] for p in projects.list_projects(conn)]
|
||||
assert all("issues_count" not in p and set(p) == {"label", "board_id", "board_type"} for p in got)
|
||||
|
||||
@ -56,17 +56,17 @@ def test_bad_name(conn, name):
|
||||
@pytest.mark.parametrize("board", [0, -1, True, "5120", 1.5])
|
||||
def test_bad_board(conn, board):
|
||||
with pytest.raises(projects.ProjectError) as e:
|
||||
projects.create(conn, "AWPCA", "АРМ КА", board)
|
||||
jira_projects.create(conn, "AWPCA", "АРМ КА", board)
|
||||
assert e.value.field == "board_id"
|
||||
assert projects.list_projects(conn) == []
|
||||
|
||||
|
||||
def test_create_and_list(conn):
|
||||
a = projects.create(conn, "AWPCA", " АРМ КА ", 4216)
|
||||
assert a == {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": a["created_at"]}
|
||||
projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК")
|
||||
a = jira_projects.create(conn, "AWPCA", " АРМ КА ", 4216)
|
||||
assert a == {"label": "AWPCA", "name": "АРМ КА", "created_at": a["created_at"]}
|
||||
jira_projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК")
|
||||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}, {"key": "A-2", "labels": ["awpca", "x"]}], [])
|
||||
got = projects.list_projects(conn)
|
||||
got = api.project_rows(conn)
|
||||
assert [(p["label"], p["board_id"], p["issues_count"]) for p in got] == [("AWPCA", 4216, 1), ("STOCKS_DRRK", None, 0)]
|
||||
|
||||
|
||||
@ -100,18 +100,18 @@ def test_base_jql_single_and_reserved_words(conn):
|
||||
|
||||
|
||||
def test_boards_dedup_and_types(conn):
|
||||
projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120)
|
||||
projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d")
|
||||
jira_projects.create(conn, "AWPCA", "a", 4216); jira_projects.create(conn, "B", "b", 5120)
|
||||
jira_projects.create(conn, "C", "c", 4216); jira_projects.create(conn, "D", "d")
|
||||
jira_projects.record_board_types(conn, {"4216": "scrum"})
|
||||
assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
|
||||
assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской
|
||||
assert api.project_rows(conn, ["C"])[0]["board_type"] == "scrum" # тип — у всех проектов с этой доской
|
||||
|
||||
|
||||
@pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}])
|
||||
def test_record_board_types_ignores(conn, boards):
|
||||
projects.create(conn, "B", "b", 5120)
|
||||
jira_projects.create(conn, "B", "b", 5120)
|
||||
assert jira_projects.record_board_types(conn, boards) == 0
|
||||
assert projects.get(conn, "B")["board_type"] is None
|
||||
assert api.project_rows(conn, ["B"])[0]["board_type"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("jql, labels", [
|
||||
@ -136,7 +136,7 @@ def test_add_project_fixture(conn, add_project):
|
||||
|
||||
def test_ensure_legacy(conn):
|
||||
assert jira_projects.ensure_legacy(conn, 4216) is True
|
||||
assert projects.get(conn, "AWPCA")["board_id"] == 4216 and projects.get(conn, "AWPCA")["name"] == "АРМ КА"
|
||||
assert api.project_rows(conn, ["AWPCA"])[0]["board_id"] == 4216 and projects.get(conn, "AWPCA")["name"] == "АРМ КА"
|
||||
assert jira_projects.ensure_legacy(conn, 1) is False
|
||||
|
||||
|
||||
@ -147,4 +147,4 @@ def test_ensure_legacy_skips_case_variant_and_bad_board(open_db):
|
||||
assert jira_projects.ensure_legacy(c, 4216) is False
|
||||
c2 = open_db()
|
||||
settings.ensure_defaults(c2)
|
||||
assert jira_projects.ensure_legacy(c2, "oops") is True and projects.get(c2, "AWPCA")["board_id"] is None
|
||||
assert jira_projects.ensure_legacy(c2, "oops") is True and api.project_rows(c2, ["AWPCA"])[0]["board_id"] is None
|
||||
|
||||
@ -2,7 +2,7 @@ import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, files, projects, queue, settings
|
||||
from jira_hub.core import api, db, files, projects, queue, settings
|
||||
from jira_hub.modules.jira import attachments, commands, mirror, refresh
|
||||
from jira_hub.modules.jira import fetch as jira_fetch
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
@ -879,7 +879,7 @@ def test_retry_sprint_recomputes_board(conn, add_project): # сце
|
||||
mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], [])
|
||||
b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")])
|
||||
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
|
||||
conn.execute("UPDATE project SET board_id = 5120, board_type = 'scrum'")
|
||||
conn.execute("UPDATE jira_project SET board_id = 5120, board_type = 'scrum'")
|
||||
r = queue.retry_command(conn, cid, "claude")
|
||||
assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
|
||||
|
||||
@ -927,7 +927,7 @@ def test_refresh_result_board_types(conn, add_project, result, expected):
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
c = queue.claim(conn, "jira")
|
||||
queue.report_result(conn, c["id"], "done", result=result)
|
||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == expected
|
||||
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == expected
|
||||
|
||||
|
||||
def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): # доска в Jira стала канбаном
|
||||
@ -935,7 +935,7 @@ def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project):
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
c = queue.claim(conn, "jira")
|
||||
queue.report_result(conn, c["id"], "done", result={"boards": {"5120": "kanban"}})
|
||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
|
||||
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == "kanban"
|
||||
|
||||
|
||||
def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 / T6.1: тип доски — факт о доске, а не часть выборки
|
||||
@ -943,7 +943,7 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
c = queue.claim(conn, "jira")
|
||||
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
|
||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
|
||||
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == "kanban"
|
||||
|
||||
|
||||
# --- журнал изменений: history в выдаче refresh, sync_run.history_at (#70, Task 3) ----------------------------------
|
||||
|
||||
@ -98,12 +98,13 @@ def test_core_imports():
|
||||
# типы команд и термины jira; ensure_defaults и json.dumps(ensure_ascii=...) — легитимные имена ядра
|
||||
JIRA_TERMS = re.compile(
|
||||
r"""['"]refresh['"]|\bensure_(?!defaults\b|ascii\b)\w+|\bfetch_attachment\b|\bsync_run\b|\bissue_attachment\b"""
|
||||
r"""|\battachment_file\b|\bissue_comment\b|\bjql\b|\w_jql\b|\bjql_\w+""",
|
||||
r"""|\battachment_file\b|\bissue_comment\b|\bjql\b|\w_jql\b|\bjql_\w+"""
|
||||
r"""|\bboard_(?:id|type)\b|\bjira_project\b|\bissues_count\b""",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def test_core_has_no_jira_terms():
|
||||
"""Ядро не знает типов команд и терминов jira; исключения — db.py (история миграций) и места с меткой долга."""
|
||||
"""Ядро не знает типов команд, терминов jira и полей досок; исключение — db.py (история миграций)."""
|
||||
hits = []
|
||||
for path in sorted(CORE.glob("*.py")):
|
||||
if path.name == "db.py":
|
||||
@ -112,7 +113,5 @@ def test_core_has_no_jira_terms():
|
||||
for i, line in enumerate(lines):
|
||||
if not JIRA_TERMS.search(line):
|
||||
continue
|
||||
if "долг hub-modules" in line or (i > 0 and "долг hub-modules" in lines[i - 1]):
|
||||
continue
|
||||
hits.append(f"{path.name}:{i + 1}: {line.strip()}")
|
||||
assert hits == [], "термины jira в ядре:\n" + "\n".join(hits)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user