import { EPICS, KEY_RE, NO_EPIC } from "../constants.ts"; import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts"; import { parseCsv } from "./csv.ts"; import { addLink, addStubs } from "./links.ts"; import { sortableDate } from "./util.ts"; // CSV из фильтра Jira: нужны колонки Ключ проблемы, Тема, Статус, Приоритет, Тип задачи, Метки, Связи Фичи, Плановая дата окончания, Исполнитель, Спринт // и «Внешняя/Внутренняя ссылка на проблему (ASUPR_…)». У задачи X «Внешняя ссылка (ASUPR_Блокер_Начала)» содержит Y, если X блокирует Y // (outward-описание «Блокирует начало выполнения»); «Внутренняя ссылка» — если Y блокирует X. До 11.10.2026 связи в Jira стояли // задом наперёд (ошибка направления в jira_sync.py), и страница читала «Внешняя» как блокеров; 11.10.2026 связи пересозданы, чтение исправлено. export function fromCsv(text: string, fileName: string, dicts: Dicts): BoardModel { const rows = parseCsv(text); if (rows.length < 2) throw new Error("в файле нет строк с задачами"); const head = rows[0].map((h) => h.trim()); const find = (...subs: string[]) => head.findIndex((h) => subs.some((s) => h.toLowerCase().includes(s.toLowerCase()))); const cols = { key: find("Ключ проблемы", "Ключ задачи", "Issue key", "Ключ"), sum: find("Тема", "Резюме", "Summary"), status: find("Статус", "Status"), prio: find("Приоритет", "Priority"), type: find("Тип задачи", "Тип запроса", "Issue Type"), epic: find("Связи Фичи"), due: find("Плановая дата окончания", "Срок исполнения", "Due date"), who: find("Исполнитель", "Assignee"), }; const labelCols = head.map((h, i) => (/^(Метки|Labels)$/i.test(h) ? i : -1)).filter((i) => i >= 0); const sprintCols = head.map((h, i) => (/^(Спринт|Sprint)$/i.test(h) ? i : -1)).filter((i) => i >= 0); if (cols.key < 0 || cols.sum < 0) throw new Error("не нашёл колонки «Ключ проблемы» и «Тема»; в файле: " + head.slice(0, 10).join(", ") + "…"); const linkCols: { i: number; type: string; out: boolean }[] = []; head.forEach((h, i) => { for (const [t, type] of Object.entries(dicts.linkTypes)) if (h.includes(t)) linkCols.push({ i, type, out: /outward|исход|внешн/.test(h.toLowerCase()) }); }); const problems: string[] = []; if (!linkCols.length) problems.push("в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»"); if (!sprintCols.length) problems.push("в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными"); if (cols.epic < 0) problems.push("нет колонки «Связи Фичи» — дорожки по эпикам не построить"); const cell = (r: string[], i: number) => (i >= 0 ? r[i] || "" : "").trim(); const isEpic = (r: string[]) => cols.type >= 0 && /^(Фича|Epic|Эпик)$/i.test(cell(r, cols.type)); const epicNames: Record = { ...EPICS }; rows.slice(1).forEach((r) => { const k = cell(r, cols.key); if (KEY_RE.test(k) && isEpic(r)) epicNames[k] = cell(r, cols.sum) || k; }); const nodes: BoardNode[] = [], links: Link[] = [], seen = new Set(), sprints: Record = {}; for (const r of rows.slice(1)) { const key = cell(r, cols.key); if (!KEY_RE.test(key) || isEpic(r)) continue; let kind = cell(r, cols.type); for (const c of labelCols) { const v = cell(r, c); if (dicts.kindLabels[v]) kind = dicts.kindLabels[v]; } const epicKey = cell(r, cols.epic); const sprint = sprintCols.map((c) => cell(r, c)).filter(Boolean).pop() || ""; if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1; nodes.push({ key, title: cell(r, cols.sum), kind, prio: cell(r, cols.prio), due: sortableDate(cell(r, cols.due)), status: cell(r, cols.status), epic: epicKey ? (epicNames[epicKey] || epicKey) : NO_EPIC, sprint, who: cell(r, cols.who), stub: false }); for (const lc of linkCols) { const other = cell(r, lc.i); if (!KEY_RE.test(other)) continue; addLink(links, seen, lc.out ? key : other, lc.out ? other : key, lc.type); } } const missing = addStubs(nodes, links); return { nodes, links, sprints, fileName, problems, hasSprint: sprintCols.length > 0, missing }; }