From 47495fa5db48e597e9fb6d5a92118758fd0450b1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:26:06 +0400 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20CSV-=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20admin/jira-?= =?UTF-8?q?sync=20=D0=B2=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B8,=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/importer.py | 75 ++++++++++++++++++++++++++++++++++++++++ tests/test_importer.py | 52 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/jira_hub/importer.py create mode 100644 tests/test_importer.py diff --git a/src/jira_hub/importer.py b/src/jira_hub/importer.py new file mode 100644 index 0000000..f0ce8f6 --- /dev/null +++ b/src/jira_hub/importer.py @@ -0,0 +1,75 @@ +"""Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор +(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует.""" +import csv +from pathlib import Path + +from . import mirror, settings + + +def _rows(path: Path) -> list[dict]: + if not path.exists(): + return [] + with open(path, encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def _clean(value) -> str | None: + value = (value or "").strip() + return value or None + + +def import_package(conn, folder) -> dict: + folder = Path(folder) + notes = 0 + csv_keys = [] + + for r in _rows(folder / "tasks.csv"): + key = _clean(r.get("Код")) + if not key: + continue + mirror.set_note(conn, key, { + "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")), + "owner_note": _clean(r.get("Комментарий актуализации"))}, "import") + notes += 1 + csv_keys.append(key) + + new_keys = {} # new:<строка> → ключ Jira (как в jira_sync.py) + for i, r in enumerate(_rows(folder / "new_tasks.csv"), 2): + real = _clean(r.get("Ключ")) + key = real or f"new:{i}" + if real: + new_keys[f"new:{i}"] = real + csv_keys.append(real) + mirror.set_note(conn, key, { + "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "rest_text": _clean(r.get("Основание (внутр.)")), + "owner_note": None if real else _clean(r.get("Тема"))}, "import") + notes += 1 + + for r in _rows(folder / "epics.csv"): + key = _clean(r.get("Ключ")) + if not key: + continue + mirror.set_note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}, "import") + notes += 1 + csv_keys.append(key) + + link_types = settings.get(conn, "link_types") or {} + expected, missing_links = [], [] + for r in _rows(folder / "links.csv"): + src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда"))) + dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда"))) + kind = (_clean(r.get("Тип")) or "").lower() + if not src or not dst or src.startswith("new:") or dst.startswith("new:"): + continue + link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)} + expected.append(link) + issue = mirror.get_issue(conn, src) + present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]) + if not present: + missing_links.append(link) + + known = set(mirror.known_keys(conn)) + missing_keys = sorted({k for k in csv_keys if k not in known}) + return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links} diff --git a/tests/test_importer.py b/tests/test_importer.py new file mode 100644 index 0000000..6f5e2db --- /dev/null +++ b/tests/test_importer.py @@ -0,0 +1,52 @@ +import csv + +from jira_hub import db, importer, mirror, settings + + +def write_csv(path, header, rows): + with open(path, "w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + w.writerow(header) + w.writerows(rows) + + +def make_package(folder): + write_csv(folder / "tasks.csv", + ["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации", + "Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"], + [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", "Актуализация 10.10.2026. Порядок", + "", "Прогнать на стенде", "Спринт 2"], + ["AURORA-1416", "Карточка", "UI", "В работе", "", "Сделан список", "Модалка", ""]]) + write_csv(folder / "new_tasks.csv", + ["Ключ", "Тема", "Вид работы", "Статус по факту", "Описание", "Основание (внутр.)", "Спринт"], + [["AURORA-1586", "Справочники: тестирование", "тестирование", "Ожидание разработки", "h3. Контекст", "решение 11.10", "Спринт 2"], + ["", "Ещё не создана", "разработка", "Ожидание разработки", "h3. Контекст", "идея", ""]]) + write_csv(folder / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"], + [["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"], ["NEW-1", "AURORA-1529", "Роли", "новый"]]) + write_csv(folder / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], + [["AURORA-1533", "блокирует начало", "AURORA-1416", "x"], + ["new:2", "связанные", "AURORA-1533", "y"], + ["new:3", "связанные", "AURORA-1533", "z"]]) + + +def test_import_notes_and_report(tmp_path): + make_package(tmp_path) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) + mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], + [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) + rep = importer.import_package(conn, tmp_path) + assert rep["notes"] == 6 + n = mirror.get_note(conn, "AURORA-1533") + assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование" + assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import" + assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список" + assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10" + assert mirror.get_note(conn, "new:3")["owner_note"] == "Ещё не создана" + assert mirror.get_note(conn, "AURORA-1529")["work_kind"] == "эпик" + assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"] + assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется + assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] + assert importer.import_package(conn, tmp_path)["notes"] == 6 + assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6