From acbc19c7eb4eba8e045cbcb383fe7fe28817e860 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:29:08 +0400 Subject: [PATCH] =?UTF-8?q?fix(hub):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=85?= =?UTF-8?q?=D0=B0=D0=B1=D0=B0=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=BC=D0=B8?= =?UTF-8?q?=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B0=D0=BC=D0=B8=20CSV,=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=81=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=20=D0=B2=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пустая ячейка CSV не меняет существующее поле заметки; заметка без непустых полей не пишется, но учитывается в счётчике как раньше. Импорт обёрнут в db.transaction (set_note — SAVEPOINT). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/importer.py | 27 ++++++++++++++++------ tests/test_importer.py | 49 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/jira_hub/importer.py b/src/jira_hub/importer.py index f0ce8f6..6f269cb 100644 --- a/src/jira_hub/importer.py +++ b/src/jira_hub/importer.py @@ -1,9 +1,10 @@ """Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор -(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует.""" +(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует. +Пустая ячейка CSV не меняет существующее поле заметки (правки, сделанные в хабе, сохраняются).""" import csv from pathlib import Path -from . import mirror, settings +from . import db, mirror, settings def _rows(path: Path) -> list[dict]: @@ -18,8 +19,20 @@ def _clean(value) -> str | None: return value or None +def _note(conn, key, fields: dict) -> None: + """Записать только непустые поля; если их нет — заметку не трогаем.""" + fields = {k: v for k, v in fields.items() if v is not None} + if fields: + mirror.set_note(conn, key, fields, "import") + + def import_package(conn, folder) -> dict: folder = Path(folder) + with db.transaction(conn): + return _import(conn, folder) + + +def _import(conn, folder: Path) -> dict: notes = 0 csv_keys = [] @@ -27,10 +40,10 @@ def import_package(conn, folder) -> dict: key = _clean(r.get("Код")) if not key: continue - mirror.set_note(conn, key, { + _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") + "owner_note": _clean(r.get("Комментарий актуализации"))}) notes += 1 csv_keys.append(key) @@ -41,17 +54,17 @@ def import_package(conn, folder) -> dict: if real: new_keys[f"new:{i}"] = real csv_keys.append(real) - mirror.set_note(conn, key, { + _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") + "owner_note": None if real else _clean(r.get("Тема"))}) 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") + _note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))}) notes += 1 csv_keys.append(key) diff --git a/tests/test_importer.py b/tests/test_importer.py index 6f5e2db..7e77125 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -50,3 +50,52 @@ def test_import_notes_and_report(tmp_path): 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 + + +def write_tasks(folder, comment): + write_csv(folder / "tasks.csv", + ["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации", + "Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"], + [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]]) + + +def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path): + write_tasks(tmp_path, "Актуализация 10.10.2026") + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + importer.import_package(conn, tmp_path) + mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub") + write_tasks(tmp_path, "") # пустая ячейка в CSV + importer.import_package(conn, tmp_path) + n = mirror.get_note(conn, "AURORA-1533") + assert n["owner_note"] == "правка в хабе" + assert n["rest_text"] == "Прогнать" + + +def test_reimport_updates_nonempty_cell(tmp_path): + write_tasks(tmp_path, "Первая версия") + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + importer.import_package(conn, tmp_path) + write_tasks(tmp_path, "Вторая версия") + importer.import_package(conn, tmp_path) + assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия" + + +def test_import_does_not_touch_issue_tables(tmp_path): + make_package(tmp_path) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}], + [{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}]) + count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] + before = (count("issue"), count("issue_link")) + importer.import_package(conn, tmp_path) + assert (count("issue"), count("issue_link")) == before + + +def test_missing_csv_files_give_empty_report(tmp_path): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + rep = importer.import_package(conn, tmp_path) + assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": []}