feat(hub): импорт CSV-пакета admin/jira-sync в заметки, сверка связей

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:26:06 +04:00
parent a67ce091e1
commit 47495fa5db
2 changed files with 127 additions and 0 deletions

75
src/jira_hub/importer.py Normal file
View File

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

52
tests/test_importer.py Normal file
View File

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