118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
/** Direct markdown parsing of backlog task files for AC read/toggle. */
|
|
|
|
import { readdir } from "node:fs/promises";
|
|
import { repoPath } from "./checklist-config";
|
|
|
|
const AC_BEGIN = "<!-- AC:BEGIN -->";
|
|
const AC_END = "<!-- AC:END -->";
|
|
const AC_REGEX = /^- \[([ x])\] #(\d+) (.+)$/;
|
|
|
|
export interface AcceptanceCriterion {
|
|
index: number;
|
|
checked: boolean;
|
|
text: string;
|
|
}
|
|
|
|
export interface TaskInfo {
|
|
title: string;
|
|
status: string;
|
|
criteria: AcceptanceCriterion[];
|
|
filePath: string;
|
|
}
|
|
|
|
/**
|
|
* Find the task markdown file in the backlog directory.
|
|
* Files are named like "task-high.3 - Some-title.md" where the taskId
|
|
* is the prefix before " - ". Case-insensitive match.
|
|
*/
|
|
async function findTaskFile(repo: string, taskId: string): Promise<string> {
|
|
const base = repoPath(repo);
|
|
const tasksDir = `${base}/backlog/tasks`;
|
|
|
|
const exact = `${tasksDir}/${taskId}.md`;
|
|
if (await Bun.file(exact).exists()) return exact;
|
|
|
|
const files = await readdir(tasksDir);
|
|
const lower = taskId.toLowerCase();
|
|
const match = files.find((f) => f.toLowerCase().startsWith(lower + " - ") && f.endsWith(".md"));
|
|
if (match) return `${tasksDir}/${match}`;
|
|
|
|
throw new Error(`Task file not found for ${taskId} in ${tasksDir}`);
|
|
}
|
|
|
|
function parseTitle(content: string): string {
|
|
const fmMatch = content.match(/^---\n[\s\S]*?title:\s*["']?(.+?)["']?\s*\n[\s\S]*?---/);
|
|
if (fmMatch?.[1]) return fmMatch[1];
|
|
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
return headingMatch?.[1] || "Untitled Task";
|
|
}
|
|
|
|
function parseStatus(content: string): string {
|
|
const match = content.match(/^status:\s*["']?(.+?)["']?\s*$/m);
|
|
return match?.[1] || "Unknown";
|
|
}
|
|
|
|
function parseAC(content: string): AcceptanceCriterion[] {
|
|
const normalized = content.replace(/\r\n/g, "\n");
|
|
const beginIdx = normalized.indexOf(AC_BEGIN);
|
|
const endIdx = normalized.indexOf(AC_END);
|
|
if (beginIdx === -1 || endIdx === -1) return [];
|
|
|
|
const acContent = normalized.substring(beginIdx + AC_BEGIN.length, endIdx);
|
|
const lines = acContent.split("\n").filter((l) => l.trim());
|
|
const criteria: AcceptanceCriterion[] = [];
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(AC_REGEX);
|
|
if (match?.[1] && match?.[2] && match?.[3]) {
|
|
criteria.push({
|
|
checked: match[1] === "x",
|
|
text: match[3],
|
|
index: parseInt(match[2], 10),
|
|
});
|
|
}
|
|
}
|
|
return criteria;
|
|
}
|
|
|
|
export async function readTask(repo: string, taskId: string): Promise<TaskInfo> {
|
|
const filePath = await findTaskFile(repo, taskId);
|
|
const content = await Bun.file(filePath).text();
|
|
return {
|
|
title: parseTitle(content),
|
|
status: parseStatus(content),
|
|
criteria: parseAC(content),
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Toggle an AC item: unchecked → checked, checked → unchecked.
|
|
* Returns the new checked state.
|
|
*/
|
|
export async function toggleAC(repo: string, taskId: string, acIndex: number): Promise<TaskInfo & { toggled: boolean }> {
|
|
const filePath = await findTaskFile(repo, taskId);
|
|
let content = await Bun.file(filePath).text();
|
|
|
|
const unchecked = `- [ ] #${acIndex} `;
|
|
const checked = `- [x] #${acIndex} `;
|
|
let nowChecked = false;
|
|
if (content.includes(unchecked)) {
|
|
content = content.replace(unchecked, checked);
|
|
nowChecked = true;
|
|
await Bun.write(filePath, content);
|
|
} else if (content.includes(checked)) {
|
|
content = content.replace(checked, unchecked);
|
|
nowChecked = false;
|
|
await Bun.write(filePath, content);
|
|
}
|
|
|
|
return {
|
|
title: parseTitle(content),
|
|
status: parseStatus(content),
|
|
criteria: parseAC(content),
|
|
filePath,
|
|
toggled: nowChecked,
|
|
};
|
|
}
|