rspace-online/modules/rtasks/lib/backlog.ts

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,
};
}