Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions src/utils/task-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,45 @@ export function countTasksFromContent(content: string): TaskProgress {
return { total, completed };
}

export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress> {
const tasksPath = path.join(changesDir, changeName, 'tasks.md');
async function collectTaskFiles(dir: string): Promise<string[]> {
let entries;
try {
const content = await fs.readFile(tasksPath, 'utf-8');
return countTasksFromContent(content);
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return { total: 0, completed: 0 };
return [];
}

const taskFiles: string[] = [];
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
taskFiles.push(...await collectTaskFiles(entryPath));
} else if (entry.isFile() && entry.name === 'tasks.md') {
taskFiles.push(entryPath);
}
}
return taskFiles;
}

export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress> {
const changeDir = path.join(changesDir, changeName);
const taskFiles = await collectTaskFiles(changeDir);

let total = 0;
let completed = 0;
for (const tasksPath of taskFiles.sort()) {
let content;
try {
content = await fs.readFile(tasksPath, 'utf-8');
} catch {
continue;
}
const progress = countTasksFromContent(content);
total += progress.total;
completed += progress.completed;
}

return { total, completed };
}

export function formatTaskStatus(progress: TaskProgress): string {
Expand Down
26 changes: 26 additions & 0 deletions test/core/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,31 @@ describe('ViewCommand', () => {
'gamma-change'
]);
});

it('uses nested tasks.md files when classifying change progress', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
const changeDir = path.join(changesDir, 'layered-change');
await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true });
await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true });
await fs.writeFile(
path.join(changeDir, 'backend', 'tasks.md'),
'- [x] Backend task\n- [ ] Backend follow-up\n'
);
await fs.writeFile(
path.join(changeDir, 'frontend', 'tasks.md'),
'- [ ] Frontend task\n'
);

const viewCommand = new ViewCommand();
await viewCommand.execute(tempDir);

const output = logOutput.map(stripAnsi).join('\n');
expect(output).toContain('Active Changes');
expect(output).toContain('layered-change');
expect(output).toContain('33%');

const draftSection = output.split('Draft Changes')[1]?.split('Active Changes')[0] ?? '';
expect(draftSection).not.toContain('layered-change');
});
});

Loading