refactor: пакеты core и modules.jira — перенос без логики (#87)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 21:50:23 +04:00
parent 808d7b710c
commit 6d7aebe01c
43 changed files with 175 additions and 139 deletions

View File

@ -1,87 +1 @@
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens,
agent publish (версия агента для самообновления)."""
import argparse
import json
import sys
from . import db, importer, paths, projects, 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")
p = sub.add_parser("mcp", help="MCP-сервер по stdio")
p.add_argument("--project", help="метка проекта, к которому привязана сессия")
sub.add_parser("tokens", help="показать токены агента и клиента")
p = sub.add_parser("agent", help="версия агента для самообновления")
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)")
p.add_argument("path")
args = ap.parse_args(argv)
if args.cmd == "agent":
from . import release
try:
m = release.publish(paths.release_dir(), args.path)
except release.PublishError as e:
print(e, file=sys.stderr)
return 1
print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт")
return 0
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(args.project)
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))
if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")):
board = projects.get(conn, projects.LEGACY_LABEL)["board_id"]
print(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}")
_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())
from .core.cli import main

View File

View File

@ -15,8 +15,9 @@ from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, quote, unquote, urlparse
from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings
from .commands import CommandError
from . import backup, db, files, projects, queue, relay, release, settings
from ..modules.jira import attachments, history, mirror
from ..modules.jira.commands import CommandError
from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
from .queue import ClaimedError
@ -24,7 +25,7 @@ from .queue import ClaimedError
log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос
WEB_APP = Path(__file__).resolve().parent.parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос
ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)

88
src/jira_hub/core/cli.py Normal file
View File

@ -0,0 +1,88 @@
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens,
agent publish (версия агента для самообновления)."""
import argparse
import json
import sys
from . import db, paths, projects, settings
from ..modules.jira import importer
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")
p = sub.add_parser("mcp", help="MCP-сервер по stdio")
p.add_argument("--project", help="метка проекта, к которому привязана сессия")
sub.add_parser("tokens", help="показать токены агента и клиента")
p = sub.add_parser("agent", help="версия агента для самообновления")
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)")
p.add_argument("path")
args = ap.parse_args(argv)
if args.cmd == "agent":
from . import release
try:
m = release.publish(paths.release_dir(), args.path)
except release.PublishError as e:
print(e, file=sys.stderr)
return 1
print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт")
return 0
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(args.project)
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))
if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")):
board = projects.get(conn, projects.LEGACY_LABEL)["board_id"]
print(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}")
_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())

View File

@ -15,9 +15,9 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP
from . import paths
from .attachments import fmt_size
from ..modules.jira.attachments import fmt_size
from .client import HubClient, HubError, HubUnavailable
from .commands import describe, is_temp
from ..modules.jira.commands import describe, is_temp
from .files import MAX_SIZE
OFFLINE = "хаб не запущен: jira-hub serve"
@ -25,7 +25,7 @@ POLL_SECONDS = 5
AUTHOR = "claude"
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
HUB_ROOT = Path(__file__).resolve().parents[2] # корень репозитория хаба, из которого загружен пакет (решение 19)
HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19)
PROJECT: str | None = None # привязка сессии, задаётся run(project)
mcp = FastMCP("jira-hub")

View File

@ -6,8 +6,9 @@ import logging
from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import commands as C
from . import attachments, db, files, history, mirror, projects, settings
from ..modules.jira import commands as C
from . import db, files, projects, settings
from ..modules.jira import attachments, history, mirror
log = logging.getLogger(__name__)

View File

View File

View File

@ -3,7 +3,7 @@
базу и хранилище файлов, очередь и HTTP не знает."""
import re
from . import files
from ...core import files
# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки;
# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя

View File

@ -2,7 +2,7 @@
refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model)."""
from datetime import timedelta
from . import db, projects, settings
from ...core import db, projects, settings
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)

View File

@ -4,7 +4,8 @@
import csv
from pathlib import Path
from . import db, mirror, settings
from . import mirror
from ...core import db, settings
def _rows(path: Path) -> list[dict]:

View File

@ -2,7 +2,8 @@
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
from . import attachments, db, history
from . import attachments, history
from ...core 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"]

View File

@ -2,7 +2,7 @@
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
import pytest
from jira_hub import db, projects
from jira_hub.core import db, projects
@pytest.fixture

View File

@ -11,9 +11,9 @@ from datetime import timedelta
import pytest
from jira_hub import api, db, files, projects, queue, release, settings
from jira_hub.client import HubClient, HubError
from jira_hub.commands import CommandError
from jira_hub.core import api, db, files, projects, queue, release, settings
from jira_hub.core.client import HubClient, HubError
from jira_hub.modules.jira.commands import CommandError
@pytest.fixture
@ -157,7 +157,7 @@ def test_backup_created_on_first_write(hub, tmp_path):
def test_client_unavailable():
from jira_hub.client import HubUnavailable
from jira_hub.core.client import HubUnavailable
with pytest.raises(HubUnavailable):
HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues")
@ -1040,7 +1040,7 @@ ATT_NAME = "скрин 1.png"
def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME):
conn = hub["hub"].conn()
try:
from jira_hub import mirror
from jira_hub.modules.jira import mirror
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
finally:

View File

@ -2,7 +2,8 @@ import json
import pytest
from jira_hub import attachments, db, files, mirror
from jira_hub.core import db, files
from jira_hub.modules.jira import attachments, mirror
def test_fmt_size():

View File

@ -5,7 +5,7 @@ import json
import os
from pathlib import Path
from jira_hub import api, db, mcp_server, settings
from jira_hub.core import api, db, mcp_server, settings
FIXTURES = Path(__file__).parent / "fixtures" / "baseline"

View File

@ -2,7 +2,7 @@ import json
import os
from pathlib import Path
from jira_hub import cli, paths, projects, release, settings
from jira_hub.core import cli, paths, projects, release, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
@ -67,7 +67,7 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
def test_mcp_project_option(monkeypatch):
from jira_hub import mcp_server
from jira_hub.core import mcp_server
seen = []
monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project))
assert cli.main(["mcp", "--project", "AWPCA"]) == 0

View File

@ -1,7 +1,7 @@
import pytest
from jira_hub import commands, mirror
from jira_hub.commands import CommandError
from jira_hub.modules.jira import commands, mirror
from jira_hub.modules.jira.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"

View File

@ -7,7 +7,7 @@ import threading
import pytest
from jira_hub import backup, db, paths, projects, queue, settings
from jira_hub.core import backup, db, paths, projects, queue, settings
def test_connect_creates_schema(open_db):
@ -154,7 +154,7 @@ def test_data_dir_is_private(tmp_path):
def test_existing_backups_dir_made_private_on_start(tmp_path):
from jira_hub import api
from jira_hub.core import api
backups = tmp_path / "backups"
backups.mkdir()
backups.chmod(0o755)

View File

@ -2,8 +2,8 @@ import threading
import pytest
from jira_hub import api, settings
from jira_hub.client import HubClient, HubError
from jira_hub.core import api, settings
from jira_hub.core.client import HubClient, HubError
@pytest.fixture

View File

@ -7,7 +7,7 @@ import time
import pytest
from jira_hub import db, files, settings
from jira_hub.core import db, files, settings
ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf…
DAY = 86400

View File

@ -1,6 +1,6 @@
import pytest
from jira_hub import history
from jira_hub.modules.jira import history
ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр",
"field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001",
@ -29,7 +29,7 @@ def test_record_keeps_first_version(conn):
def test_record_uses_field_settings(conn):
from jira_hub import settings
from jira_hub.core import settings
settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"})
history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}])
assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end"
@ -92,7 +92,7 @@ def test_feed_closed_in_two_hours(conn): # сце
def test_feed_comment(conn): # «Комментарий в ленте»
from jira_hub import mirror
from jira_hub.modules.jira import mirror
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [
{"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], [])
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS)
@ -163,7 +163,7 @@ def test_parse_kinds_unknown():
def test_issue_changes(conn): # «Задача с историей»
from jira_hub import mirror
from jira_hub.modules.jira import mirror
_issue(conn, "X", ["AWPCA"])
_change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b")
_change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description")

View File

@ -2,7 +2,8 @@ import csv
import pytest
from jira_hub import importer, mirror, settings
from jira_hub.core import settings
from jira_hub.modules.jira import importer, mirror
@pytest.fixture

23
tests/test_layout.py Normal file
View File

@ -0,0 +1,23 @@
"""Раскладка пакетов: чистый импорт и пути, зависящие от места файла."""
import subprocess
import sys
from pathlib import Path
import jira_hub
def test_fresh_imports():
r = subprocess.run(
[sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"],
capture_output=True, text=True)
assert r.returncode == 0, r.stderr
def test_web_app_path():
from jira_hub.core import api
assert api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app"
def test_hub_root():
from jira_hub.core import mcp_server
assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file()

View File

@ -9,8 +9,9 @@ from types import SimpleNamespace
import pytest
from jira_hub import api, commands, files, mcp_server, settings
from jira_hub.client import HubClient
from jira_hub.core import api, files, mcp_server, settings
from jira_hub.modules.jira import commands
from jira_hub.core.client import HubClient
@pytest.fixture
@ -811,7 +812,7 @@ def test_attachment_failed(downloads, monkeypatch):
def test_attachment_413(downloads, monkeypatch):
from jira_hub.client import HubError
from jira_hub.core.client import HubError
att = _att("5", "запись.mp4", 26214400)
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
_use(monkeypatch, hub)
@ -820,7 +821,7 @@ def test_attachment_413(downloads, monkeypatch):
def test_attachment_409_passes_hub_text(downloads, monkeypatch):
from jira_hub.client import HubError
from jira_hub.core.client import HubError
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]),
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
_use(monkeypatch, hub)

View File

@ -1,6 +1,7 @@
import pytest
from jira_hub import db, mirror
from jira_hub.core import db
from jira_hub.modules.jira import mirror
@pytest.fixture

View File

@ -1,6 +1,7 @@
import pytest
from jira_hub import mirror, projects, settings
from jira_hub.core import projects, settings
from jira_hub.modules.jira import mirror
@pytest.fixture

View File

@ -2,9 +2,10 @@ import hashlib
import pytest
from jira_hub import attachments, db, files, mirror, projects, queue, settings
from jira_hub.commands import CommandError
from jira_hub.queue import ClaimedError
from jira_hub.core import db, files, projects, queue, settings
from jira_hub.modules.jira import attachments, mirror
from jira_hub.modules.jira.commands import CommandError
from jira_hub.core.queue import ClaimedError
LT = {"связанные": "ASUPR_Связанные"}
@ -207,7 +208,7 @@ def test_no_backup_for_refresh_batch(conn):
def test_make_backup_rotates(tmp_path, open_db):
from jira_hub import backup
from jira_hub.core import backup
conn = open_db(tmp_path / "hub.sqlite")
for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
@ -218,7 +219,7 @@ def test_make_backup_rotates(tmp_path, open_db):
def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
from jira_hub import backup
from jira_hub.core import backup
conn = open_db(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
for i in range(4):

View File

@ -8,8 +8,8 @@ from datetime import datetime, timedelta, timezone
import pytest
from jira_hub import api, relay, settings
from jira_hub.client import HubClient
from jira_hub.core import api, relay, settings
from jira_hub.core.client import HubClient
@pytest.fixture

View File

@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from jira_hub import release
from jira_hub.core import release
def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"):

View File

@ -3,8 +3,8 @@ from pathlib import Path
import pytest
from jira_hub import queue, settings
from jira_hub.commands import CommandError
from jira_hub.core import queue, settings
from jira_hub.modules.jira.commands import CommandError
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"

View File

@ -5,7 +5,7 @@ from urllib.parse import urlparse
import pytest
from jira_hub import api
from jira_hub.core import api
def _get(base, path):

View File

@ -5,7 +5,8 @@ import json
import sys
from pathlib import Path
from jira_hub import db, mirror, paths, settings
from jira_hub.core import db, paths, settings
from jira_hub.modules.jira import mirror
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
extras_path = Path(__file__).with_name("seed-extras.json")