fix(hub): импорт не затирает правки хаба пустыми ячейками CSV, весь импорт в транзакции

Пустая ячейка CSV не меняет существующее поле заметки; заметка без непустых полей не пишется,
но учитывается в счётчике как раньше. Импорт обёрнут в db.transaction (set_note — SAVEPOINT).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 22:29:08 +04:00
parent 6216039526
commit acbc19c7eb
2 changed files with 69 additions and 7 deletions

View File

@ -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)

View File

@ -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": []}