feat(tasktree): import MDTODO files and reports
This commit is contained in:
@@ -60,6 +60,7 @@ export type TaskTreeCommand =
|
||||
| { operation: "task.delete"; taskId: string }
|
||||
| { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string }
|
||||
| { operation: "report.create"; taskId: string; title: string; body: string; status?: string }
|
||||
| { operation: "mdtodo.import"; sourcePath: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string }>; groupName?: string; dryRun?: boolean }
|
||||
| { operation: "timeline.get"; groupId: string }
|
||||
| { operation: "workflow.start"; taskId: string };
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Client, Connection } from "@temporalio/client";
|
||||
|
||||
import type { TaskTreeCommand, TaskTreeResult } from "./contracts.ts";
|
||||
import { parseMdtodoImport } from "./mdtodo-import.ts";
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
export type TaskTreeDispatcherOptions = {
|
||||
@@ -26,6 +27,10 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
|
||||
else if (operation === "task.delete") data = { deleted: await store.deleteTask(command.taskId) };
|
||||
else if (operation === "milestone.create") data = await store.createMilestone({ ...command, title: requiredText(command.title, "title"), occursAt: validDate(command.occursAt, "occursAt") });
|
||||
else if (operation === "report.create") data = await store.createReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") });
|
||||
else if (operation === "mdtodo.import") {
|
||||
const plan = parseMdtodoImport(command);
|
||||
data = command.dryRun ? { dryRun: true, ...plan, tasks: undefined } : await store.importMdtodo(plan);
|
||||
}
|
||||
else if (operation === "timeline.get") data = required(await store.timeline(command.groupId), "group_not_found", "taskgroup was not found");
|
||||
else if (operation === "workflow.start") data = await startTaskWorkflow(options, command.taskId);
|
||||
else throw codedError("unsupported_operation", `unsupported operation: ${String(operation)}`);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { parseMdtodoImport } from "./mdtodo-import.ts";
|
||||
|
||||
test("MDTODO import preserves status, flattens deep Rxx tasks, and attaches reports", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
markdown: `# Demo\n\n## R1 Root [in_progress]\n\nSee [任务报告](./details/R1_Task_Report.md).\n\n### R1.1 Child [completed]\n\n#### R1.1.1 Deep child [blocked]\n`,
|
||||
reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed." }]
|
||||
});
|
||||
|
||||
assert.equal(plan.groupName, "Demo");
|
||||
assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 2, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 1, warningCount: 1 });
|
||||
assert.equal(plan.tasks[0]?.status, "in_progress");
|
||||
assert.equal(plan.tasks[1]?.status, "completed");
|
||||
assert.equal(plan.tasks[2]?.status, "blocked");
|
||||
assert.equal(plan.tasks[2]?.parentSourceId, "R1");
|
||||
assert.equal(plan.tasks[0]?.reports[0]?.body, "# R1 report\n\nPassed.");
|
||||
});
|
||||
|
||||
test("MDTODO import reports missing linked report files", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
markdown: "# Demo\n\n## R1 Root\n\n[Task report](./R1_Task_Report.md)\n",
|
||||
reports: []
|
||||
});
|
||||
assert.equal(plan.summary.missingReportCount, 1);
|
||||
assert.equal(plan.warnings[0]?.code, "report_file_missing");
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { TaskStatus } from "./contracts.ts";
|
||||
|
||||
export type MdtodoReportSource = { href: string; sourcePath: string; body: string };
|
||||
export type MdtodoImportPayload = {
|
||||
sourcePath: string;
|
||||
markdown: string;
|
||||
reports: MdtodoReportSource[];
|
||||
groupName?: string;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export type MdtodoImportTask = {
|
||||
sourceId: string;
|
||||
parentSourceId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
reports: Array<{ title: string; body: string; status: string }>;
|
||||
};
|
||||
|
||||
export type MdtodoImportPlan = {
|
||||
sourcePath: string;
|
||||
groupName: string;
|
||||
groupDescription: string;
|
||||
parserMode: "rxx" | "legacy-checkbox";
|
||||
tasks: MdtodoImportTask[];
|
||||
summary: {
|
||||
taskCount: number;
|
||||
subtaskCount: number;
|
||||
reportCount: number;
|
||||
missingReportCount: number;
|
||||
flattenedTaskCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
warnings: Array<{ code: string; message: string; sourceId?: string; href?: string }>;
|
||||
};
|
||||
|
||||
const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu;
|
||||
const checkboxPattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
|
||||
const markdownLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/gu;
|
||||
|
||||
export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise<MdtodoImportPayload> {
|
||||
const sourcePath = path.resolve(filePath);
|
||||
const markdown = await readFile(sourcePath, "utf8");
|
||||
const reports: MdtodoReportSource[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const link of reportLinks(markdown)) {
|
||||
const reportPath = path.resolve(path.dirname(sourcePath), link.href);
|
||||
if (seen.has(reportPath)) continue;
|
||||
seen.add(reportPath);
|
||||
try {
|
||||
reports.push({ href: link.href, sourcePath: reportPath, body: await readFile(reportPath, "utf8") });
|
||||
} catch {
|
||||
// Missing report files are reported by the pure parser so dry-run and import agree.
|
||||
}
|
||||
}
|
||||
return { sourcePath, markdown, reports, groupName: options.groupName, dryRun: options.dryRun };
|
||||
}
|
||||
|
||||
export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPlan {
|
||||
const lines = String(payload.markdown ?? "").split(/\r?\n/u);
|
||||
const rxxBlocks = collectRxxBlocks(lines);
|
||||
const parsed = rxxBlocks.length ? parseRxx(lines, rxxBlocks, payload) : parseCheckboxes(lines, payload);
|
||||
if (!parsed.tasks.length) throw codedError("mdtodo_tasks_not_found", "MDTODO file does not contain Rxx headings or checkbox tasks");
|
||||
const reportCount = parsed.tasks.reduce((count, task) => count + task.reports.length, 0);
|
||||
const missingReportCount = parsed.warnings.filter((warning) => warning.code === "report_file_missing").length;
|
||||
const flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length;
|
||||
return {
|
||||
sourcePath: payload.sourcePath,
|
||||
groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)),
|
||||
groupDescription: `Imported from MDTODO: ${payload.sourcePath}`,
|
||||
parserMode: rxxBlocks.length ? "rxx" : "legacy-checkbox",
|
||||
tasks: parsed.tasks,
|
||||
summary: {
|
||||
taskCount: parsed.tasks.filter((task) => !task.parentSourceId).length,
|
||||
subtaskCount: parsed.tasks.filter((task) => task.parentSourceId).length,
|
||||
reportCount,
|
||||
missingReportCount,
|
||||
flattenedTaskCount,
|
||||
warningCount: parsed.warnings.length
|
||||
},
|
||||
warnings: parsed.warnings.slice(0, 100)
|
||||
};
|
||||
}
|
||||
|
||||
function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) {
|
||||
const tasks: MdtodoImportTask[] = [];
|
||||
const warnings: MdtodoImportPlan["warnings"] = [];
|
||||
const roots = new Set(blocks.filter((block) => rxxDepth(block.sourceId) === 0).map((block) => block.sourceId));
|
||||
for (const block of blocks) {
|
||||
const depth = rxxDepth(block.sourceId);
|
||||
const rootId = `R${block.sourceId.slice(1).split(".")[0]}`;
|
||||
const parentSourceId = depth > 0 && roots.has(rootId) ? rootId : null;
|
||||
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to a subtask of ${rootId}` });
|
||||
if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable root task and was imported as a task` });
|
||||
const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim();
|
||||
tasks.push({
|
||||
sourceId: block.sourceId,
|
||||
parentSourceId,
|
||||
title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`),
|
||||
description,
|
||||
status: block.status,
|
||||
reports: reportsForTask(description, payload, block.sourceId, warnings)
|
||||
});
|
||||
}
|
||||
return { tasks, warnings };
|
||||
}
|
||||
|
||||
function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) {
|
||||
const tasks: MdtodoImportTask[] = [];
|
||||
const warnings: MdtodoImportPlan["warnings"] = [];
|
||||
const roots: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = checkboxPattern.exec(lines[index]);
|
||||
if (!match) continue;
|
||||
const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2));
|
||||
const sourceId = `L${index + 1}`;
|
||||
if (depth === 0) roots.push(sourceId);
|
||||
const parentSourceId = depth > 0 ? roots.at(-1) ?? null : null;
|
||||
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the nearest root checkbox task` });
|
||||
tasks.push({
|
||||
sourceId,
|
||||
parentSourceId,
|
||||
title: cleanTitle(match[3]),
|
||||
description: "",
|
||||
status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending",
|
||||
reports: []
|
||||
});
|
||||
}
|
||||
return { tasks, warnings };
|
||||
}
|
||||
|
||||
function reportsForTask(body: string, payload: MdtodoImportPayload, sourceId: string, warnings: MdtodoImportPlan["warnings"]) {
|
||||
const reports: MdtodoImportTask["reports"] = [];
|
||||
for (const link of reportLinks(body)) {
|
||||
const resolved = path.resolve(path.dirname(payload.sourcePath), link.href);
|
||||
const report = payload.reports.find((candidate) => path.resolve(candidate.sourcePath) === resolved);
|
||||
if (!report) {
|
||||
warnings.push({ code: "report_file_missing", sourceId, href: link.href, message: `Report file could not be read: ${link.href}` });
|
||||
continue;
|
||||
}
|
||||
reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported" });
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
function reportLinks(markdown: string) {
|
||||
const links: Array<{ label: string; href: string }> = [];
|
||||
for (const match of String(markdown ?? "").matchAll(markdownLinkPattern)) {
|
||||
const label = String(match[1] ?? "").trim();
|
||||
const href = String(match[2] ?? "").trim().replace(/^<|>$/gu, "").split(/\s+/u)[0] ?? "";
|
||||
if (!href || !/\.md(?:#.*)?$/iu.test(href) || /^(?:[a-z][a-z0-9+.-]*:|\/|[A-Za-z]:\/)/iu.test(href)) continue;
|
||||
if (!/(?:任务报告|task[_ -]?report|report)/iu.test(`${label} ${href}`)) continue;
|
||||
links.push({ label, href: href.split("#")[0] });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
type RxxBlock = { sourceId: string; title: string; status: TaskStatus; lineIndex: number; endLine: number };
|
||||
function collectRxxBlocks(lines: string[]): RxxBlock[] {
|
||||
const blocks: Omit<RxxBlock, "endLine">[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = rxxHeadingPattern.exec(lines[index]);
|
||||
if (!match) continue;
|
||||
const rest = String(match[3] ?? "").trim();
|
||||
blocks.push({ sourceId: match[2].replace(/^r/iu, "R"), title: stripStatus(rest), status: statusFrom(rest), lineIndex: index });
|
||||
}
|
||||
return blocks.map((block, index) => ({ ...block, endLine: blocks[index + 1]?.lineIndex ?? lines.length }));
|
||||
}
|
||||
|
||||
function statusFrom(value: string): TaskStatus {
|
||||
const markers = [...value.matchAll(/\[([^\]]+)\]/gu)].map((match) => String(match[1]).trim().toLowerCase().replace(/[\s-]+/gu, "_"));
|
||||
if (markers.some((marker) => marker === "blocked")) return "blocked";
|
||||
if (markers.some((marker) => ["completed", "finished", "done"].includes(marker))) return "completed";
|
||||
if (markers.some((marker) => ["in_progress", "processing"].includes(marker))) return "in_progress";
|
||||
return "pending";
|
||||
}
|
||||
function stripStatus(value: string) { return value.replace(/\s*\[(?:open|todo|blocked|in[_ -]?progress|processing|completed|finished|done)\]\s*/giu, " ").trim(); }
|
||||
function rxxDepth(sourceId: string) { return Math.max(0, sourceId.split(".").length - 1); }
|
||||
function documentTitle(lines: string[], sourcePath: string) { return cleanTitle(lines.find((line) => /^#{1,6}\s+\S/u.test(line))?.replace(/^#{1,6}\s+/u, "") || path.basename(sourcePath, path.extname(sourcePath))); }
|
||||
function firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; }
|
||||
function conciseBodyTitle(body: string) {
|
||||
const line = firstBodyLine(body);
|
||||
const separator = line.search(/[::]/u);
|
||||
return separator >= 6 && separator <= 120 ? line.slice(0, separator) : line;
|
||||
}
|
||||
function cleanTitle(value: string) { return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task"; }
|
||||
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import pg from "pg";
|
||||
|
||||
import type { ExecutionReport, Milestone, TaskGroup, TaskItem, TaskStatus, Timeline } from "./contracts.ts";
|
||||
import type { MdtodoImportPlan } from "./mdtodo-import.ts";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
@@ -140,6 +141,45 @@ export class TaskTreeStore {
|
||||
return reportRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async importMdtodo(plan: MdtodoImportPlan) {
|
||||
await this.ensureSchema();
|
||||
const client = await this.pool.connect();
|
||||
const groupId = `tg_${randomUUID()}`;
|
||||
const taskIds = new Map<string, string>();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const groupResult = await client.query(
|
||||
"INSERT INTO tasktree_groups (id,name,description) VALUES ($1,$2,$3) RETURNING *",
|
||||
[groupId, plan.groupName, plan.groupDescription]
|
||||
);
|
||||
for (let index = 0; index < plan.tasks.length; index += 1) {
|
||||
const task = plan.tasks[index];
|
||||
const taskId = `tt_${randomUUID()}`;
|
||||
taskIds.set(task.sourceId, taskId);
|
||||
const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null;
|
||||
const kind = parentId ? "subtask" : "task";
|
||||
await client.query(
|
||||
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
[taskId, groupId, parentId, kind, task.title, task.description, task.status, index]
|
||||
);
|
||||
for (const report of task.reports) {
|
||||
await client.query(
|
||||
"INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5)",
|
||||
[`tr_${randomUUID()}`, taskId, report.title, report.body, report.status]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return { dryRun: false, sourcePath: plan.sourcePath, group: groupRow(groupResult.rows[0]), parserMode: plan.parserMode, summary: plan.summary, warnings: plan.warnings };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async timeline(groupId: string): Promise<Timeline | null> {
|
||||
await this.ensureSchema();
|
||||
const group = await this.getGroup(groupId);
|
||||
|
||||
Reference in New Issue
Block a user