206 lines
5.6 KiB
TypeScript
206 lines
5.6 KiB
TypeScript
/**
|
|
* headless-import-backlog.ts — deterministic markdown→SF-DB backlog importer.
|
|
*
|
|
* Parses a flat markdown file with H2 sections (## Title) into SF milestones,
|
|
* and bullet list items under each H2 into slices. No LLM, no RPC child.
|
|
* Writes directly to `.sf/sf.db` via the SF DB layer.
|
|
*
|
|
* Usage: sf headless import-backlog <file.md>
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
export interface ImportBacklogOptions {
|
|
json: boolean;
|
|
}
|
|
|
|
interface ParsedSlice {
|
|
title: string;
|
|
status: string;
|
|
}
|
|
|
|
interface ParsedMilestone {
|
|
title: string;
|
|
slices: ParsedSlice[];
|
|
}
|
|
|
|
/**
|
|
* Convert a title string to a safe kebab-case slug for use as an SF ID.
|
|
* SF milestone IDs must match /^[a-z0-9]+(-[a-z0-9]+)*$/.
|
|
*/
|
|
function slugify(title: string): string {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, "")
|
|
.trim()
|
|
.replace(/[\s-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48);
|
|
}
|
|
|
|
/**
|
|
* Parse a markdown backlog document into a list of milestones with slices.
|
|
*
|
|
* Sections are delimited by H2 headings (## Title). Bullet list items directly
|
|
* under a heading are collected as slices. Text paragraphs are ignored (they
|
|
* become part of the milestone vision if present).
|
|
*/
|
|
export function parseBacklogMarkdown(text: string): ParsedMilestone[] {
|
|
const milestones: ParsedMilestone[] = [];
|
|
let current: ParsedMilestone | null = null;
|
|
|
|
for (const rawLine of text.split("\n")) {
|
|
const line = rawLine.trimEnd();
|
|
|
|
const h2 = line.match(/^##\s+(.+)$/);
|
|
if (h2) {
|
|
current = { title: h2[1].trim(), slices: [] };
|
|
milestones.push(current);
|
|
continue;
|
|
}
|
|
|
|
if (!current) continue;
|
|
|
|
// Bullet items: -, *, +, or numbered (1. ...) — strip status emoji/markers
|
|
const bullet = line.match(/^\s*[-*+]\s+(.+)$/) ?? line.match(/^\s*\d+\.\s+(.+)$/);
|
|
if (bullet) {
|
|
let title = bullet[1].trim();
|
|
// Strip leading status markers: ✅, 🟡, ⬜, ✓, x, [x], [ ], etc.
|
|
title = title.replace(/^[✅🟡⬜✓✗x]\s+/u, "");
|
|
title = title.replace(/^\[[x ]\]\s+/i, "");
|
|
// Detect done status from emoji prefix in original
|
|
const isDone =
|
|
bullet[1].trim().startsWith("✅") ||
|
|
bullet[1].trim().match(/^\[x\]/i) != null;
|
|
if (title) {
|
|
current.slices.push({
|
|
title,
|
|
status: isDone ? "complete" : "pending",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return milestones.filter((m) => m.title.length > 0);
|
|
}
|
|
|
|
/**
|
|
* Run the import. Opens the SF DB, parses the backlog file, and upserts
|
|
* milestones + slices. Skips milestones whose slugged ID already exists.
|
|
*/
|
|
export async function runImportBacklog(
|
|
filePath: string,
|
|
cwd: string,
|
|
opts: ImportBacklogOptions,
|
|
): Promise<number> {
|
|
const log = opts.json
|
|
? () => {}
|
|
: (msg: string) => process.stderr.write(`[import-backlog] ${msg}\n`);
|
|
|
|
if (!existsSync(filePath)) {
|
|
process.stderr.write(
|
|
`[import-backlog] Error: file not found: ${filePath}\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
const sfDir = join(cwd, ".sf");
|
|
if (!existsSync(sfDir)) {
|
|
process.stderr.write(
|
|
`[import-backlog] Error: no .sf directory found in ${cwd}\n` +
|
|
` Run 'sf headless init' first to bootstrap the project.\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// Open the SF database
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const dynamicToolsPath = "./resources/extensions/sf/bootstrap/dynamic-tools.js";
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { ensureDbOpen } = await import(dynamicToolsPath) as any;
|
|
const opened = await ensureDbOpen(cwd);
|
|
if (!opened) {
|
|
process.stderr.write(
|
|
`[import-backlog] Error: could not open .sf/sf.db in ${cwd}\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const sfDbPath = "./resources/extensions/sf/sf-db.js";
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { insertMilestone, getMilestone, insertSlice, getAllMilestones } =
|
|
await import(sfDbPath) as any;
|
|
|
|
const text = readFileSync(filePath, "utf8");
|
|
const parsed = parseBacklogMarkdown(text);
|
|
|
|
if (parsed.length === 0) {
|
|
process.stderr.write(
|
|
`[import-backlog] No H2 sections found in ${filePath}\n` +
|
|
` Expected format: ## Section Title, with optional bullet items below.\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
log(`Parsed ${parsed.length} milestone(s) from ${filePath}`);
|
|
|
|
// Determine the next sequence number to preserve import order
|
|
const existing = getAllMilestones();
|
|
let sequence = existing.length;
|
|
|
|
const results: { id: string; title: string; slices: number; skipped: boolean }[] = [];
|
|
|
|
for (const m of parsed) {
|
|
const id = slugify(m.title);
|
|
if (!id) {
|
|
log(`Skipping milestone with unslugifiable title: "${m.title}"`);
|
|
continue;
|
|
}
|
|
|
|
const exists = getMilestone(id) != null;
|
|
if (exists) {
|
|
log(`Skipping existing milestone: ${id}`);
|
|
results.push({ id, title: m.title, slices: 0, skipped: true });
|
|
continue;
|
|
}
|
|
|
|
insertMilestone({
|
|
id,
|
|
title: m.title,
|
|
status: "queued",
|
|
sequence: sequence++,
|
|
});
|
|
|
|
let sliceSeq = 0;
|
|
for (const s of m.slices) {
|
|
const sliceId = `s${String(++sliceSeq).padStart(2, "0")}`;
|
|
insertSlice({
|
|
milestoneId: id,
|
|
id: sliceId,
|
|
title: s.title,
|
|
status: s.status,
|
|
sequence: sliceSeq,
|
|
});
|
|
}
|
|
|
|
log(` + ${id}: "${m.title}" (${m.slices.length} slices)`);
|
|
results.push({ id, title: m.title, slices: m.slices.length, skipped: false });
|
|
}
|
|
|
|
const imported = results.filter((r) => !r.skipped).length;
|
|
const skipped = results.filter((r) => r.skipped).length;
|
|
|
|
if (opts.json) {
|
|
process.stdout.write(
|
|
JSON.stringify({ schemaVersion: 1, imported, skipped, milestones: results }) + "\n",
|
|
);
|
|
} else {
|
|
process.stderr.write(
|
|
`[import-backlog] Done: ${imported} imported, ${skipped} skipped.\n`,
|
|
);
|
|
}
|
|
|
|
return 0;
|
|
}
|