mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(list): Add --ready and --blocking filters to identify parallelizable tasks (#1533)
Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> - fixes #1532
This commit is contained in:
16
.changeset/list-blocks-ready-filter.md
Normal file
16
.changeset/list-blocks-ready-filter.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
"task-master-ai": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add --ready and --blocking filters to list command for identifying parallelizable tasks
|
||||||
|
|
||||||
|
- Add `--ready` filter to show only tasks with satisfied dependencies (ready to work on)
|
||||||
|
- Add `--blocking` filter to show only tasks that block other tasks
|
||||||
|
- Combine `--ready --blocking` to find high-impact tasks (ready AND blocking others)
|
||||||
|
- Add "Blocks" column to task table showing which tasks depend on each task
|
||||||
|
- Blocks field included in JSON output for programmatic access
|
||||||
|
- Add "Ready" column to `tags` command showing count of ready tasks per tag
|
||||||
|
- Add `--ready` filter to `tags` command to show only tags with available work
|
||||||
|
- Excludes deferred/blocked tasks from ready count (only actionable statuses)
|
||||||
|
- Add `--all-tags` option to list ready tasks across all tags (use with `--ready`)
|
||||||
|
- Tag column shown as first column when using `--all-tags` for easy scanning
|
||||||
959
apps/cli/src/commands/list.command.spec.ts
Normal file
959
apps/cli/src/commands/list.command.spec.ts
Normal file
@@ -0,0 +1,959 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for ListTasksCommand
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TmCore } from '@tm/core';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock dependencies - use importOriginal to preserve real implementations
|
||||||
|
// Only mock createTmCore since we inject a mock tmCore directly in tests
|
||||||
|
vi.mock('@tm/core', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@tm/core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createTmCore: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../utils/project-root.js', () => ({
|
||||||
|
getProjectRoot: vi.fn((path?: string) => path || '/test/project')
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/error-handler.js', () => ({
|
||||||
|
displayError: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/display-helpers.js', () => ({
|
||||||
|
displayCommandHeader: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ui/index.js', () => ({
|
||||||
|
calculateDependencyStatistics: vi.fn(() => ({ total: 0, blocked: 0 })),
|
||||||
|
calculateSubtaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })),
|
||||||
|
calculateTaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })),
|
||||||
|
displayDashboards: vi.fn(),
|
||||||
|
displayRecommendedNextTask: vi.fn(),
|
||||||
|
displaySuggestedNextSteps: vi.fn(),
|
||||||
|
getPriorityBreakdown: vi.fn(() => ({})),
|
||||||
|
getTaskDescription: vi.fn(() => 'Test description')
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/ui.js', () => ({
|
||||||
|
createTaskTable: vi.fn(() => 'Table output'),
|
||||||
|
displayWarning: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ListTasksCommand } from './list.command.js';
|
||||||
|
|
||||||
|
describe('ListTasksCommand', () => {
|
||||||
|
let consoleLogSpy: any;
|
||||||
|
let mockTmCore: Partial<TmCore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
mockTmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: [{ id: '1', title: 'Test Task', status: 'pending' }],
|
||||||
|
total: 1,
|
||||||
|
filtered: 1,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
} as any,
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JSON output format', () => {
|
||||||
|
it('should use JSON format when --json flag is set', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Mock the tmCore initialization
|
||||||
|
(command as any).tmCore = mockTmCore;
|
||||||
|
|
||||||
|
// Execute with --json flag
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
json: true,
|
||||||
|
format: 'text' // Should be overridden by --json
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify JSON output was called
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalled();
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
|
||||||
|
// Should be valid JSON
|
||||||
|
expect(() => JSON.parse(output)).not.toThrow();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
expect(parsed).toHaveProperty('tasks');
|
||||||
|
expect(parsed).toHaveProperty('metadata');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override --format when --json is set', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
(command as any).tmCore = mockTmCore;
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
json: true,
|
||||||
|
format: 'compact' // Should be overridden
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should output JSON, not compact format
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
expect(() => JSON.parse(output)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use specified format when --json is not set', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
(command as any).tmCore = mockTmCore;
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
format: 'compact'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should use compact format (not JSON)
|
||||||
|
const output = consoleLogSpy.mock.calls;
|
||||||
|
// In compact mode, output is not JSON
|
||||||
|
expect(output.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to text format when neither flag is set', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
(command as any).tmCore = mockTmCore;
|
||||||
|
|
||||||
|
await (command as any).executeCommand({});
|
||||||
|
|
||||||
|
// Should use text format (not JSON)
|
||||||
|
// If any console.log was called, verify it's not JSON
|
||||||
|
if (consoleLogSpy.mock.calls.length > 0) {
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
// Text format output should not be parseable JSON
|
||||||
|
// or should be the table string we mocked
|
||||||
|
expect(
|
||||||
|
output === 'Table output' ||
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
JSON.parse(output);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format validation', () => {
|
||||||
|
it('should accept valid formats', () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
expect((command as any).validateOptions({ format: 'text' })).toBe(true);
|
||||||
|
expect((command as any).validateOptions({ format: 'json' })).toBe(true);
|
||||||
|
expect((command as any).validateOptions({ format: 'compact' })).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid formats', () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
expect((command as any).validateOptions({ format: 'invalid' })).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Invalid format: invalid')
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--ready filter', () => {
|
||||||
|
it('should filter to only tasks with all dependencies satisfied', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Mock tasks where some have satisfied deps and some don't
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'done', dependencies: [] },
|
||||||
|
{ id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, // deps satisfied (1 is done)
|
||||||
|
{ id: '3', title: 'Task 3', status: 'pending', dependencies: ['2'] }, // deps NOT satisfied (2 is pending)
|
||||||
|
{ id: '4', title: 'Task 4', status: 'pending', dependencies: [] } // no deps, ready
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 4,
|
||||||
|
filtered: 4,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
ready: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Should only include tasks 2 and 4 (ready to work on)
|
||||||
|
expect(parsed.tasks).toHaveLength(2);
|
||||||
|
expect(parsed.tasks.map((t: any) => t.id)).toEqual(
|
||||||
|
expect.arrayContaining(['2', '4'])
|
||||||
|
);
|
||||||
|
expect(parsed.tasks.map((t: any) => t.id)).not.toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude done/cancelled tasks from ready filter', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'done', dependencies: [] },
|
||||||
|
{ id: '2', title: 'Task 2', status: 'cancelled', dependencies: [] },
|
||||||
|
{ id: '3', title: 'Task 3', status: 'pending', dependencies: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 3,
|
||||||
|
filtered: 3,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
ready: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Should only include task 3 (pending with no deps)
|
||||||
|
expect(parsed.tasks).toHaveLength(1);
|
||||||
|
expect(parsed.tasks[0].id).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude deferred and blocked tasks from ready filter', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'pending', dependencies: [] },
|
||||||
|
{ id: '2', title: 'Task 2', status: 'deferred', dependencies: [] },
|
||||||
|
{ id: '3', title: 'Task 3', status: 'blocked', dependencies: [] },
|
||||||
|
{ id: '4', title: 'Task 4', status: 'in-progress', dependencies: [] },
|
||||||
|
{ id: '5', title: 'Task 5', status: 'review', dependencies: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 5,
|
||||||
|
filtered: 5,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
ready: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Should only include pending, in-progress, and review tasks
|
||||||
|
expect(parsed.tasks).toHaveLength(3);
|
||||||
|
const ids = parsed.tasks.map((t: any) => t.id);
|
||||||
|
expect(ids).toContain('1'); // pending
|
||||||
|
expect(ids).toContain('4'); // in-progress
|
||||||
|
expect(ids).toContain('5'); // review
|
||||||
|
expect(ids).not.toContain('2'); // deferred - excluded
|
||||||
|
expect(ids).not.toContain('3'); // blocked - excluded
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--blocking filter', () => {
|
||||||
|
it('should filter to only tasks that block other tasks', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'pending', dependencies: [] }, // blocks 2, 3
|
||||||
|
{ id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, // blocks 4
|
||||||
|
{ id: '3', title: 'Task 3', status: 'pending', dependencies: ['1'] }, // blocks nothing
|
||||||
|
{ id: '4', title: 'Task 4', status: 'pending', dependencies: ['2'] } // blocks nothing
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 4,
|
||||||
|
filtered: 4,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
blocking: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Should only include tasks 1 and 2 (they block other tasks)
|
||||||
|
expect(parsed.tasks).toHaveLength(2);
|
||||||
|
expect(parsed.tasks.map((t: any) => t.id)).toEqual(
|
||||||
|
expect.arrayContaining(['1', '2'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--ready --blocking combined filter', () => {
|
||||||
|
it('should show high-impact tasks (ready AND blocking)', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'done', dependencies: [] },
|
||||||
|
{ id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, // ready (1 done), blocks 3,4
|
||||||
|
{ id: '3', title: 'Task 3', status: 'pending', dependencies: ['2'] }, // not ready, blocks 5
|
||||||
|
{ id: '4', title: 'Task 4', status: 'pending', dependencies: ['2'] }, // not ready, blocks nothing
|
||||||
|
{ id: '5', title: 'Task 5', status: 'pending', dependencies: ['3'] }, // not ready, blocks nothing
|
||||||
|
{ id: '6', title: 'Task 6', status: 'pending', dependencies: [] } // ready, blocks nothing
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 6,
|
||||||
|
filtered: 6,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
ready: true,
|
||||||
|
blocking: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Should only include task 2 (ready AND blocks other tasks)
|
||||||
|
expect(parsed.tasks).toHaveLength(1);
|
||||||
|
expect(parsed.tasks[0].id).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blocks field in output', () => {
|
||||||
|
it('should include blocks field showing which tasks depend on each task', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'pending', dependencies: [] },
|
||||||
|
{ id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] },
|
||||||
|
{ id: '3', title: 'Task 3', status: 'pending', dependencies: ['1'] },
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Task 4',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['2', '3']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 4,
|
||||||
|
filtered: 4,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Task 1 blocks tasks 2 and 3
|
||||||
|
const task1 = parsed.tasks.find((t: any) => t.id === '1');
|
||||||
|
expect(task1.blocks).toEqual(expect.arrayContaining(['2', '3']));
|
||||||
|
|
||||||
|
// Task 2 blocks task 4
|
||||||
|
const task2 = parsed.tasks.find((t: any) => t.id === '2');
|
||||||
|
expect(task2.blocks).toEqual(['4']);
|
||||||
|
|
||||||
|
// Task 3 blocks task 4
|
||||||
|
const task3 = parsed.tasks.find((t: any) => t.id === '3');
|
||||||
|
expect(task3.blocks).toEqual(['4']);
|
||||||
|
|
||||||
|
// Task 4 blocks nothing
|
||||||
|
const task4 = parsed.tasks.find((t: any) => t.id === '4');
|
||||||
|
expect(task4.blocks).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--ready filter edge cases', () => {
|
||||||
|
it('should treat cancelled dependencies as satisfied for --ready filter', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Task 1 is cancelled, Task 2 depends on Task 1
|
||||||
|
// Task 2 should be considered "ready" because cancelled = complete
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Cancelled Task',
|
||||||
|
status: 'cancelled',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Dependent Task',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['1']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
ready: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Task 2 should be ready because task 1 (cancelled) counts as complete
|
||||||
|
expect(parsed.tasks).toHaveLength(1);
|
||||||
|
expect(parsed.tasks[0].id).toBe('2');
|
||||||
|
expect(parsed.tasks[0].status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply status filter after ready filter', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Multiple ready tasks with different statuses
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Done', status: 'done', dependencies: [] },
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Pending ready',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'In-progress ready',
|
||||||
|
status: 'in-progress',
|
||||||
|
dependencies: ['1']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 3,
|
||||||
|
filtered: 3,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
ready: true,
|
||||||
|
status: 'pending',
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// With --ready --status=pending, should only show task 2
|
||||||
|
// Task 2 is ready (dep 1 is done) and pending
|
||||||
|
// Task 3 is ready but in-progress, not pending
|
||||||
|
expect(parsed.tasks).toHaveLength(1);
|
||||||
|
expect(parsed.tasks[0].id).toBe('2');
|
||||||
|
expect(parsed.tasks[0].status).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildBlocksMap validation', () => {
|
||||||
|
it('should warn about dependencies to non-existent tasks', async () => {
|
||||||
|
const consoleWarnSpy = vi
|
||||||
|
.spyOn(console, 'warn')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Task with bad dep',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['999']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tasks: mockTasks,
|
||||||
|
total: 1,
|
||||||
|
filtered: 1,
|
||||||
|
storageType: 'json'
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify console.warn was called with warning about invalid dependency
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Find the call that mentions invalid dependency references
|
||||||
|
const warnCalls = consoleWarnSpy.mock.calls.map((call) => call[0]);
|
||||||
|
const hasInvalidDepWarning = warnCalls.some(
|
||||||
|
(msg) =>
|
||||||
|
typeof msg === 'string' &&
|
||||||
|
msg.includes('invalid dependency reference')
|
||||||
|
);
|
||||||
|
const hasSpecificTaskWarning = warnCalls.some(
|
||||||
|
(msg) =>
|
||||||
|
typeof msg === 'string' &&
|
||||||
|
msg.includes('Task 1') &&
|
||||||
|
msg.includes('999')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasInvalidDepWarning).toBe(true);
|
||||||
|
expect(hasSpecificTaskWarning).toBe(true);
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--all-tags option', () => {
|
||||||
|
it('should fetch tasks from multiple tags and include tagName field', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Mock tasks for different tags
|
||||||
|
const featureATasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Feature A Task 1',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Feature A Task 2',
|
||||||
|
status: 'done',
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const featureBTasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Feature B Task 1',
|
||||||
|
status: 'in-progress',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Feature B Task 2',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['1']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const listMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(featureATasksResponse)
|
||||||
|
.mockResolvedValueOnce(featureBTasksResponse);
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: listMock,
|
||||||
|
getTagsWithStats: vi.fn().mockResolvedValue({
|
||||||
|
tags: [
|
||||||
|
{ name: 'feature-a', taskCount: 2 },
|
||||||
|
{ name: 'feature-b', taskCount: 2 }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
allTags: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Should include tasks from both tags
|
||||||
|
expect(parsed.tasks).toHaveLength(4);
|
||||||
|
|
||||||
|
// Each task should have tagName field
|
||||||
|
const featureATasks = parsed.tasks.filter(
|
||||||
|
(t: any) => t.tagName === 'feature-a'
|
||||||
|
);
|
||||||
|
const featureBTasks = parsed.tasks.filter(
|
||||||
|
(t: any) => t.tagName === 'feature-b'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(featureATasks).toHaveLength(2);
|
||||||
|
expect(featureBTasks).toHaveLength(2);
|
||||||
|
|
||||||
|
// Verify metadata indicates all tags
|
||||||
|
expect(parsed.metadata.allTags).toBe(true);
|
||||||
|
expect(parsed.metadata.tag).toBe('all');
|
||||||
|
expect(parsed.metadata.total).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply --ready filter per-tag when combined with --all-tags', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Tag A: Task 1 is done, Task 2 depends on Task 1 (ready)
|
||||||
|
const tagATasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{ id: '1', title: 'Tag A Task 1', status: 'done', dependencies: [] },
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Tag A Task 2',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['1']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag B: Task 1 is pending (ready), Task 2 depends on Task 1 (not ready)
|
||||||
|
// Note: Task IDs can overlap between tags, but dependencies are tag-scoped
|
||||||
|
const tagBTasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Tag B Task 1',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Tag B Task 2',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['1']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const listMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(tagATasksResponse)
|
||||||
|
.mockResolvedValueOnce(tagBTasksResponse);
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: listMock,
|
||||||
|
getTagsWithStats: vi.fn().mockResolvedValue({
|
||||||
|
tags: [
|
||||||
|
{ name: 'tag-a', taskCount: 2 },
|
||||||
|
{ name: 'tag-b', taskCount: 2 }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
allTags: true,
|
||||||
|
ready: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Tag A: Task 2 is ready (Task 1 is done)
|
||||||
|
// Tag B: Task 1 is ready (no deps), Task 2 is NOT ready (Task 1 is pending)
|
||||||
|
expect(parsed.tasks).toHaveLength(2);
|
||||||
|
|
||||||
|
const taskIds = parsed.tasks.map((t: any) => `${t.tagName}:${t.id}`);
|
||||||
|
expect(taskIds).toContain('tag-a:2'); // Ready: deps satisfied
|
||||||
|
expect(taskIds).toContain('tag-b:1'); // Ready: no deps
|
||||||
|
expect(taskIds).not.toContain('tag-b:2'); // Not ready: depends on pending task
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject --all-tags combined with --watch', () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const isValid = (command as any).validateOptions({
|
||||||
|
allTags: true,
|
||||||
|
watch: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('--all-tags cannot be used with --watch mode')
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply --blocking filter with --all-tags', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
// Tag A: Task 1 blocks Task 2
|
||||||
|
const tagATasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Tag A Task 1',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Tag A Task 2',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: ['1']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag B: Task 1 blocks nothing (no other tasks depend on it)
|
||||||
|
const tagBTasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Tag B Task 1',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
filtered: 1,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const listMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(tagATasksResponse)
|
||||||
|
.mockResolvedValueOnce(tagBTasksResponse);
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: listMock,
|
||||||
|
getTagsWithStats: vi.fn().mockResolvedValue({
|
||||||
|
tags: [
|
||||||
|
{ name: 'tag-a', taskCount: 2 },
|
||||||
|
{ name: 'tag-b', taskCount: 1 }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
allTags: true,
|
||||||
|
blocking: true,
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Only Tag A Task 1 blocks other tasks
|
||||||
|
expect(parsed.tasks).toHaveLength(1);
|
||||||
|
expect(parsed.tasks[0].tagName).toBe('tag-a');
|
||||||
|
expect(parsed.tasks[0].id).toBe('1');
|
||||||
|
expect(parsed.tasks[0].blocks).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply status filter with --all-tags', async () => {
|
||||||
|
const command = new ListTasksCommand();
|
||||||
|
|
||||||
|
const tagATasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Tag A Task 1',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{ id: '2', title: 'Tag A Task 2', status: 'done', dependencies: [] }
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagBTasksResponse = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Tag B Task 1',
|
||||||
|
status: 'in-progress',
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Tag B Task 2',
|
||||||
|
status: 'pending',
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
filtered: 2,
|
||||||
|
storageType: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const listMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(tagATasksResponse)
|
||||||
|
.mockResolvedValueOnce(tagBTasksResponse);
|
||||||
|
|
||||||
|
(command as any).tmCore = {
|
||||||
|
tasks: {
|
||||||
|
list: listMock,
|
||||||
|
getTagsWithStats: vi.fn().mockResolvedValue({
|
||||||
|
tags: [
|
||||||
|
{ name: 'tag-a', taskCount: 2 },
|
||||||
|
{ name: 'tag-b', taskCount: 2 }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
getStorageType: vi.fn().mockReturnValue('json')
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
getActiveTag: vi.fn().mockReturnValue('master')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await (command as any).executeCommand({
|
||||||
|
allTags: true,
|
||||||
|
status: 'pending',
|
||||||
|
json: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = consoleLogSpy.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// Only pending tasks should be included
|
||||||
|
expect(parsed.tasks).toHaveLength(2);
|
||||||
|
expect(parsed.tasks.every((t: any) => t.status === 'pending')).toBe(true);
|
||||||
|
|
||||||
|
const taskIds = parsed.tasks.map((t: any) => `${t.tagName}:${t.id}`);
|
||||||
|
expect(taskIds).toContain('tag-a:1');
|
||||||
|
expect(taskIds).toContain('tag-b:2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,14 +5,20 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
OUTPUT_FORMATS,
|
OUTPUT_FORMATS,
|
||||||
|
buildBlocksMap,
|
||||||
|
createTmCore,
|
||||||
|
filterBlockingTasks,
|
||||||
|
filterReadyTasks,
|
||||||
|
isTaskComplete,
|
||||||
|
type InvalidDependency,
|
||||||
type OutputFormat,
|
type OutputFormat,
|
||||||
STATUS_ICONS,
|
STATUS_ICONS,
|
||||||
TASK_STATUSES,
|
TASK_STATUSES,
|
||||||
type Task,
|
type Task,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
|
type TaskWithBlocks,
|
||||||
type TmCore,
|
type TmCore,
|
||||||
type WatchSubscription,
|
type WatchSubscription
|
||||||
createTmCore
|
|
||||||
} from '@tm/core';
|
} from '@tm/core';
|
||||||
import type { StorageType } from '@tm/core';
|
import type { StorageType } from '@tm/core';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
@@ -33,7 +39,6 @@ import {
|
|||||||
import { displayCommandHeader } from '../utils/display-helpers.js';
|
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||||
import { displayError } from '../utils/error-handler.js';
|
import { displayError } from '../utils/error-handler.js';
|
||||||
import { getProjectRoot } from '../utils/project-root.js';
|
import { getProjectRoot } from '../utils/project-root.js';
|
||||||
import { isTaskComplete } from '../utils/task-status.js';
|
|
||||||
import * as ui from '../utils/ui.js';
|
import * as ui from '../utils/ui.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,17 +55,26 @@ export interface ListCommandOptions {
|
|||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
project?: string;
|
project?: string;
|
||||||
watch?: boolean;
|
watch?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
|
blocking?: boolean;
|
||||||
|
allTags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task with tag info for cross-tag listing
|
||||||
|
*/
|
||||||
|
export type TaskWithTag = TaskWithBlocks & { tagName: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result type from list command
|
* Result type from list command
|
||||||
*/
|
*/
|
||||||
export interface ListTasksResult {
|
export interface ListTasksResult {
|
||||||
tasks: Task[];
|
tasks: TaskWithBlocks[] | TaskWithTag[];
|
||||||
total: number;
|
total: number;
|
||||||
filtered: number;
|
filtered: number;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
storageType: Exclude<StorageType, 'auto'>;
|
storageType: Exclude<StorageType, 'auto'>;
|
||||||
|
allTags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +118,15 @@ export class ListTasksCommand extends Command {
|
|||||||
'Project root directory (auto-detected if not provided)'
|
'Project root directory (auto-detected if not provided)'
|
||||||
)
|
)
|
||||||
.option('-w, --watch', 'Watch for changes and update list automatically')
|
.option('-w, --watch', 'Watch for changes and update list automatically')
|
||||||
|
.option(
|
||||||
|
'--ready',
|
||||||
|
'Show only tasks ready to work on (dependencies satisfied)'
|
||||||
|
)
|
||||||
|
.option('--blocking', 'Show only tasks that block other tasks')
|
||||||
|
.option(
|
||||||
|
'--all-tags',
|
||||||
|
'Show tasks from all tags (combine with --ready for actionable tasks)'
|
||||||
|
)
|
||||||
.action(async (statusArg?: string, options?: ListCommandOptions) => {
|
.action(async (statusArg?: string, options?: ListCommandOptions) => {
|
||||||
// Handle special "all" keyword to show with subtasks
|
// Handle special "all" keyword to show with subtasks
|
||||||
let status = statusArg || options?.status;
|
let status = statusArg || options?.status;
|
||||||
@@ -142,7 +165,10 @@ export class ListTasksCommand extends Command {
|
|||||||
if (options.watch) {
|
if (options.watch) {
|
||||||
await this.watchTasks(options);
|
await this.watchTasks(options);
|
||||||
} else {
|
} else {
|
||||||
const result = await this.getTasks(options);
|
// Use cross-tag listing when --all-tags is specified
|
||||||
|
const result = options.allTags
|
||||||
|
? await this.getTasksFromAllTags(options)
|
||||||
|
: await this.getTasks(options);
|
||||||
|
|
||||||
// Store result for programmatic access
|
// Store result for programmatic access
|
||||||
this.setLastResult(result);
|
this.setLastResult(result);
|
||||||
@@ -195,8 +221,15 @@ export class ListTasksCommand extends Command {
|
|||||||
// Show sync message with timestamp
|
// Show sync message with timestamp
|
||||||
displaySyncMessage(storageType, lastSync);
|
displaySyncMessage(storageType, lastSync);
|
||||||
displayWatchFooter(storageType, lastSync);
|
displayWatchFooter(storageType, lastSync);
|
||||||
} catch {
|
} catch (refreshError: unknown) {
|
||||||
// Ignore errors during watch (e.g. partial writes)
|
// Log warning but continue watching - don't crash on transient errors
|
||||||
|
const message =
|
||||||
|
refreshError instanceof Error
|
||||||
|
? refreshError.message
|
||||||
|
: String(refreshError);
|
||||||
|
console.error(
|
||||||
|
chalk.yellow(`\nWarning: Failed to refresh tasks: ${message}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (event.type === 'error' && event.error) {
|
} else if (event.type === 'error' && event.error) {
|
||||||
console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`));
|
console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`));
|
||||||
@@ -250,6 +283,17 @@ export class ListTasksCommand extends Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate --all-tags cannot be used with --watch
|
||||||
|
if (options.allTags && options.watch) {
|
||||||
|
console.error(chalk.red('--all-tags cannot be used with --watch mode'));
|
||||||
|
console.error(
|
||||||
|
chalk.gray(
|
||||||
|
'Use --all-tags without --watch, or --watch without --all-tags'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,14 +316,20 @@ export class ListTasksCommand extends Command {
|
|||||||
throw new Error('TmCore not initialized');
|
throw new Error('TmCore not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filter
|
// Parse status filter values
|
||||||
const filter =
|
const statusFilterValues =
|
||||||
options.status && options.status !== 'all'
|
options.status && options.status !== 'all'
|
||||||
? {
|
? options.status.split(',').map((s: string) => s.trim() as TaskStatus)
|
||||||
status: options.status
|
: undefined;
|
||||||
.split(',')
|
|
||||||
.map((s: string) => s.trim() as TaskStatus)
|
// When --ready is used, we need ALL tasks to correctly compute which dependencies are satisfied
|
||||||
}
|
// So we fetch without status filter first, then apply status filter after ready/blocking
|
||||||
|
const needsAllTasks = options.ready || options.blocking;
|
||||||
|
|
||||||
|
// Build filter - skip status filter if we need all tasks for ready/blocking computation
|
||||||
|
const filter =
|
||||||
|
statusFilterValues && !needsAllTasks
|
||||||
|
? { status: statusFilterValues }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Call tm-core
|
// Call tm-core
|
||||||
@@ -289,7 +339,174 @@ export class ListTasksCommand extends Command {
|
|||||||
includeSubtasks: options.withSubtasks
|
includeSubtasks: options.withSubtasks
|
||||||
});
|
});
|
||||||
|
|
||||||
return result as ListTasksResult;
|
// Build blocks map and enrich tasks with blocks field
|
||||||
|
const { blocksMap, invalidDependencies } = buildBlocksMap(result.tasks);
|
||||||
|
this.displayInvalidDependencyWarnings(invalidDependencies);
|
||||||
|
const enrichedTasks = result.tasks.map((task) => ({
|
||||||
|
...task,
|
||||||
|
blocks: blocksMap.get(String(task.id)) || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Apply ready/blocking filters (with full task context)
|
||||||
|
let filteredTasks = enrichedTasks;
|
||||||
|
|
||||||
|
if (options.ready) {
|
||||||
|
filteredTasks = filterReadyTasks(filteredTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.blocking) {
|
||||||
|
filteredTasks = filterBlockingTasks(filteredTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status filter AFTER ready/blocking if we deferred it earlier
|
||||||
|
if (statusFilterValues && needsAllTasks) {
|
||||||
|
filteredTasks = filteredTasks.filter((task) =>
|
||||||
|
statusFilterValues.includes(task.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
filtered: filteredTasks.length
|
||||||
|
} as ListTasksResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ready tasks from all tags
|
||||||
|
* Fetches tasks from each tag and combines them with tag info
|
||||||
|
*/
|
||||||
|
private async getTasksFromAllTags(
|
||||||
|
options: ListCommandOptions
|
||||||
|
): Promise<ListTasksResult> {
|
||||||
|
if (!this.tmCore) {
|
||||||
|
throw new Error('TmCore not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tags
|
||||||
|
const tagsResult = await this.tmCore.tasks.getTagsWithStats();
|
||||||
|
const allTaggedTasks: TaskWithTag[] = [];
|
||||||
|
let totalTaskCount = 0;
|
||||||
|
const failedTags: Array<{ name: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Fetch tasks from each tag
|
||||||
|
for (const tagInfo of tagsResult.tags) {
|
||||||
|
const tagName = tagInfo.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get tasks for this tag
|
||||||
|
const result = await this.tmCore.tasks.list({
|
||||||
|
tag: tagName,
|
||||||
|
includeSubtasks: options.withSubtasks
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track total count before any filtering (consistent with getTasks)
|
||||||
|
totalTaskCount += result.tasks.length;
|
||||||
|
|
||||||
|
// Build blocks map for this tag's tasks
|
||||||
|
const { blocksMap, invalidDependencies } = buildBlocksMap(result.tasks);
|
||||||
|
this.displayInvalidDependencyWarnings(invalidDependencies, tagName);
|
||||||
|
|
||||||
|
// Enrich tasks with blocks field and tag name
|
||||||
|
const enrichedTasks: TaskWithTag[] = result.tasks.map((task) => ({
|
||||||
|
...task,
|
||||||
|
blocks: blocksMap.get(String(task.id)) || [],
|
||||||
|
tagName
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Apply ready filter per-tag to respect tag-scoped dependencies
|
||||||
|
// (task IDs may overlap between tags, so we must filter within each tag)
|
||||||
|
const tasksToAdd: TaskWithTag[] = options.ready
|
||||||
|
? (filterReadyTasks(enrichedTasks) as TaskWithTag[])
|
||||||
|
: enrichedTasks;
|
||||||
|
|
||||||
|
allTaggedTasks.push(...tasksToAdd);
|
||||||
|
} catch (tagError: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
tagError instanceof Error ? tagError.message : String(tagError);
|
||||||
|
failedTags.push({ name: tagName, error: errorMessage });
|
||||||
|
continue; // Skip this tag but continue with others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about failed tags
|
||||||
|
if (failedTags.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
chalk.yellow(
|
||||||
|
`\nWarning: Could not fetch tasks from ${failedTags.length} tag(s):`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
failedTags.forEach(({ name, error }) => {
|
||||||
|
console.warn(chalk.gray(` ${name}: ${error}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ALL tags failed, throw to surface the issue
|
||||||
|
if (
|
||||||
|
failedTags.length === tagsResult.tags.length &&
|
||||||
|
tagsResult.tags.length > 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch tasks from any tag. First error: ${failedTags[0].error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply additional filters
|
||||||
|
let filteredTasks: TaskWithTag[] = allTaggedTasks;
|
||||||
|
|
||||||
|
// Apply blocking filter if specified
|
||||||
|
if (options.blocking) {
|
||||||
|
filteredTasks = filteredTasks.filter((task) => task.blocks.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status filter if specified
|
||||||
|
if (options.status && options.status !== 'all') {
|
||||||
|
const statusValues = options.status
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim() as TaskStatus);
|
||||||
|
filteredTasks = filteredTasks.filter((task) =>
|
||||||
|
statusValues.includes(task.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: filteredTasks,
|
||||||
|
total: totalTaskCount,
|
||||||
|
filtered: filteredTasks.length,
|
||||||
|
storageType: this.tmCore.tasks.getStorageType(),
|
||||||
|
allTags: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display warnings for invalid dependency references
|
||||||
|
* @param invalidDependencies - Array of invalid dependency references from buildBlocksMap
|
||||||
|
* @param tagName - Optional tag name for context in multi-tag mode
|
||||||
|
*/
|
||||||
|
private displayInvalidDependencyWarnings(
|
||||||
|
invalidDependencies: InvalidDependency[],
|
||||||
|
tagName?: string
|
||||||
|
): void {
|
||||||
|
if (invalidDependencies.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagContext = tagName ? ` (tag: ${tagName})` : '';
|
||||||
|
console.warn(
|
||||||
|
chalk.yellow(
|
||||||
|
`\nWarning: ${invalidDependencies.length} invalid dependency reference(s) found${tagContext}:`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
invalidDependencies.slice(0, 5).forEach(({ taskId, depId }) => {
|
||||||
|
console.warn(
|
||||||
|
chalk.gray(` Task ${taskId} depends on non-existent task ${depId}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (invalidDependencies.length > 5) {
|
||||||
|
console.warn(
|
||||||
|
chalk.gray(` ...and ${invalidDependencies.length - 5} more`)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,13 +517,12 @@ export class ListTasksCommand extends Command {
|
|||||||
options: ListCommandOptions
|
options: ListCommandOptions
|
||||||
): void {
|
): void {
|
||||||
// Resolve format: --json and --compact flags override --format option
|
// Resolve format: --json and --compact flags override --format option
|
||||||
const format = (
|
let format: OutputFormat = options.format || 'text';
|
||||||
options.json
|
if (options.json) {
|
||||||
? 'json'
|
format = 'json';
|
||||||
: options.compact
|
} else if (options.compact) {
|
||||||
? 'compact'
|
format = 'compact';
|
||||||
: options.format || 'text'
|
}
|
||||||
) as OutputFormat;
|
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'json':
|
case 'json':
|
||||||
@@ -335,8 +551,9 @@ export class ListTasksCommand extends Command {
|
|||||||
metadata: {
|
metadata: {
|
||||||
total: data.total,
|
total: data.total,
|
||||||
filtered: data.filtered,
|
filtered: data.filtered,
|
||||||
tag: data.tag,
|
tag: data.allTags ? 'all' : data.tag,
|
||||||
storageType: data.storageType
|
storageType: data.storageType,
|
||||||
|
allTags: data.allTags || false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -352,29 +569,32 @@ export class ListTasksCommand extends Command {
|
|||||||
data: ListTasksResult,
|
data: ListTasksResult,
|
||||||
options: ListCommandOptions
|
options: ListCommandOptions
|
||||||
): void {
|
): void {
|
||||||
const { tasks, tag, storageType } = data;
|
const { tasks, tag, storageType, allTags } = data;
|
||||||
|
|
||||||
// Display header unless --no-header is set
|
// Display header unless --no-header is set
|
||||||
if (options.noHeader !== true) {
|
if (options.noHeader !== true) {
|
||||||
displayCommandHeader(this.tmCore, {
|
displayCommandHeader(this.tmCore, {
|
||||||
tag: tag || 'master',
|
tag: allTags ? 'all tags' : tag || 'master',
|
||||||
storageType
|
storageType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.forEach((task) => {
|
for (const task of tasks) {
|
||||||
const icon = STATUS_ICONS[task.status];
|
const icon = STATUS_ICONS[task.status];
|
||||||
console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`);
|
// Show tag in compact format when listing all tags (tasks are TaskWithTag[])
|
||||||
|
const tagPrefix =
|
||||||
|
allTags && 'tagName' in task ? chalk.magenta(`[${task.tagName}] `) : '';
|
||||||
|
console.log(`${tagPrefix}${chalk.cyan(task.id)} ${icon} ${task.title}`);
|
||||||
|
|
||||||
if (options.withSubtasks && task.subtasks?.length) {
|
if (options.withSubtasks && task.subtasks?.length) {
|
||||||
task.subtasks.forEach((subtask) => {
|
for (const subtask of task.subtasks) {
|
||||||
const subIcon = STATUS_ICONS[subtask.status];
|
const subIcon = STATUS_ICONS[subtask.status];
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}`
|
` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}`
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,12 +604,12 @@ export class ListTasksCommand extends Command {
|
|||||||
data: ListTasksResult,
|
data: ListTasksResult,
|
||||||
options: ListCommandOptions
|
options: ListCommandOptions
|
||||||
): void {
|
): void {
|
||||||
const { tasks, tag, storageType } = data;
|
const { tasks, tag, storageType, allTags } = data;
|
||||||
|
|
||||||
// Display header unless --no-header is set
|
// Display header unless --no-header is set
|
||||||
if (options.noHeader !== true) {
|
if (options.noHeader !== true) {
|
||||||
displayCommandHeader(this.tmCore, {
|
displayCommandHeader(this.tmCore, {
|
||||||
tag: tag || 'master',
|
tag: allTags ? 'all tags' : tag || 'master',
|
||||||
storageType
|
storageType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -414,43 +634,52 @@ export class ListTasksCommand extends Command {
|
|||||||
? tasks.find((t) => String(t.id) === String(nextTaskInfo.id))
|
? tasks.find((t) => String(t.id) === String(nextTaskInfo.id))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Display dashboard boxes (nextTask already has complexity from storage enrichment)
|
// Display dashboard boxes unless filtering by --ready, --blocking, or --all-tags
|
||||||
displayDashboards(
|
// (filtered/cross-tag dashboards would show misleading statistics)
|
||||||
taskStats,
|
const isFiltered = options.ready || options.blocking || allTags;
|
||||||
subtaskStats,
|
if (!isFiltered) {
|
||||||
priorityBreakdown,
|
displayDashboards(
|
||||||
depStats,
|
taskStats,
|
||||||
nextTask
|
subtaskStats,
|
||||||
);
|
priorityBreakdown,
|
||||||
|
depStats,
|
||||||
|
nextTask
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Task table
|
// Task table
|
||||||
console.log(
|
console.log(
|
||||||
ui.createTaskTable(tasks, {
|
ui.createTaskTable(tasks, {
|
||||||
showSubtasks: options.withSubtasks,
|
showSubtasks: options.withSubtasks,
|
||||||
showDependencies: true,
|
showDependencies: true,
|
||||||
showComplexity: true // Enable complexity column
|
showBlocks: true, // Show which tasks this one blocks
|
||||||
|
showComplexity: true, // Enable complexity column
|
||||||
|
showTag: allTags // Show tag column for cross-tag listing
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Display recommended next task section immediately after table
|
// Display recommended next task section immediately after table
|
||||||
// Don't show "no tasks available" message in list command - that's for tm next
|
// Skip when filtering by --ready or --blocking (user already knows what they're looking at)
|
||||||
if (nextTask) {
|
if (!isFiltered) {
|
||||||
const description = getTaskDescription(nextTask);
|
// Don't show "no tasks available" message in list command - that's for tm next
|
||||||
|
if (nextTask) {
|
||||||
|
const description = getTaskDescription(nextTask);
|
||||||
|
|
||||||
displayRecommendedNextTask({
|
displayRecommendedNextTask({
|
||||||
id: nextTask.id,
|
id: nextTask.id,
|
||||||
title: nextTask.title,
|
title: nextTask.title,
|
||||||
priority: nextTask.priority,
|
priority: nextTask.priority,
|
||||||
status: nextTask.status,
|
status: nextTask.status,
|
||||||
dependencies: nextTask.dependencies,
|
dependencies: nextTask.dependencies,
|
||||||
description,
|
description,
|
||||||
complexity: nextTask.complexity as number | undefined
|
complexity: nextTask.complexity as number | undefined
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
// If no next task, don't show any message - dashboard already shows the info
|
||||||
|
|
||||||
|
// Display suggested next steps at the end
|
||||||
|
displaySuggestedNextSteps();
|
||||||
}
|
}
|
||||||
// If no next task, don't show any message - dashboard already shows the info
|
|
||||||
|
|
||||||
// Display suggested next steps at the end
|
|
||||||
displaySuggestedNextSteps();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ describe('LoopCommand', () => {
|
|||||||
mockLoopRun.mockResolvedValue(result);
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
const execute = (loopCommand as any).execute.bind(loopCommand);
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
await execute({});
|
await execute({ sandbox: true });
|
||||||
|
|
||||||
expect(mockTmCore.loop.checkSandboxAuth).toHaveBeenCalled();
|
expect(mockTmCore.loop.checkSandboxAuth).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -405,7 +405,7 @@ describe('LoopCommand', () => {
|
|||||||
mockLoopRun.mockResolvedValue(result);
|
mockLoopRun.mockResolvedValue(result);
|
||||||
|
|
||||||
const execute = (loopCommand as any).execute.bind(loopCommand);
|
const execute = (loopCommand as any).execute.bind(loopCommand);
|
||||||
await execute({});
|
await execute({ sandbox: true });
|
||||||
|
|
||||||
expect(mockTmCore.loop.runInteractiveAuth).toHaveBeenCalled();
|
expect(mockTmCore.loop.runInteractiveAuth).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ export class TagsCommand extends Command {
|
|||||||
constructor(name?: string) {
|
constructor(name?: string) {
|
||||||
super(name || 'tags');
|
super(name || 'tags');
|
||||||
|
|
||||||
// Configure the command
|
// Configure the command with options that apply to default list action
|
||||||
this.description('Manage tags for task organization');
|
this.description('Manage tags for task organization')
|
||||||
|
.option('--show-metadata', 'Show additional tag metadata')
|
||||||
|
.option('--ready', 'Show only tags with ready tasks available');
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
this.addListCommand();
|
this.addListCommand();
|
||||||
@@ -88,9 +90,9 @@ export class TagsCommand extends Command {
|
|||||||
this.addRenameCommand();
|
this.addRenameCommand();
|
||||||
this.addCopyCommand();
|
this.addCopyCommand();
|
||||||
|
|
||||||
// Default action: list tags
|
// Default action: list tags (with options from parent command)
|
||||||
this.action(async () => {
|
this.action(async (options) => {
|
||||||
await this.executeList();
|
await this.executeList(options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ export class TagsCommand extends Command {
|
|||||||
this.command('list')
|
this.command('list')
|
||||||
.description('List all tags with statistics (default action)')
|
.description('List all tags with statistics (default action)')
|
||||||
.option('--show-metadata', 'Show additional tag metadata')
|
.option('--show-metadata', 'Show additional tag metadata')
|
||||||
|
.option('--ready', 'Show only tags with ready tasks available')
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
'after',
|
'after',
|
||||||
`
|
`
|
||||||
@@ -108,6 +111,7 @@ Examples:
|
|||||||
$ tm tags # List all tags (default)
|
$ tm tags # List all tags (default)
|
||||||
$ tm tags list # List all tags (explicit)
|
$ tm tags list # List all tags (explicit)
|
||||||
$ tm tags list --show-metadata # List with metadata
|
$ tm tags list --show-metadata # List with metadata
|
||||||
|
$ tm tags list --ready # Show only tags with parallelizable work
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
@@ -245,6 +249,7 @@ Examples:
|
|||||||
*/
|
*/
|
||||||
private async executeList(options?: {
|
private async executeList(options?: {
|
||||||
showMetadata?: boolean;
|
showMetadata?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Initialize tmCore first (needed by bridge functions)
|
// Initialize tmCore first (needed by bridge functions)
|
||||||
@@ -257,7 +262,8 @@ Examples:
|
|||||||
tasksPath,
|
tasksPath,
|
||||||
{
|
{
|
||||||
showTaskCounts: true,
|
showTaskCounts: true,
|
||||||
showMetadata: options?.showMetadata || false
|
showMetadata: options?.showMetadata || false,
|
||||||
|
ready: options?.ready || false
|
||||||
},
|
},
|
||||||
{ projectRoot },
|
{ projectRoot },
|
||||||
'text'
|
'text'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { Subtask, Task, TaskPriority } from '@tm/core';
|
import type { Subtask, Task, TaskPriority } from '@tm/core';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import Table from 'cli-table3';
|
import Table from 'cli-table3';
|
||||||
|
import { isTaskComplete } from '../../utils/task-status.js';
|
||||||
import { getComplexityWithColor } from '../formatters/complexity-formatters.js';
|
import { getComplexityWithColor } from '../formatters/complexity-formatters.js';
|
||||||
import { getPriorityWithColor } from '../formatters/priority-formatters.js';
|
import { getPriorityWithColor } from '../formatters/priority-formatters.js';
|
||||||
import { getStatusWithColor } from '../formatters/status-formatters.js';
|
import { getStatusWithColor } from '../formatters/status-formatters.js';
|
||||||
@@ -16,59 +17,94 @@ import { getBoxWidth, truncate } from '../layout/helpers.js';
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_PRIORITY: TaskPriority = 'medium';
|
const DEFAULT_PRIORITY: TaskPriority = 'medium';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task-like object that can optionally have blocks field and tag name
|
||||||
|
* Used for table display - accepts both enriched TaskWithBlocks and regular Task/Subtask
|
||||||
|
*/
|
||||||
|
export type TaskTableItem = (Task | Subtask) & {
|
||||||
|
blocks?: string[];
|
||||||
|
tagName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column width ratios indexed by number of optional columns
|
||||||
|
* Each array contains ratios for: [Tag?, ID, Title, Status, Priority, Dependencies?, Blocks?, Complexity?]
|
||||||
|
*/
|
||||||
|
const COLUMN_WIDTH_RATIOS: Record<number, number[]> = {
|
||||||
|
0: [0.1, 0.5, 0.2, 0.2],
|
||||||
|
1: [0.08, 0.4, 0.18, 0.14, 0.2],
|
||||||
|
2: [0.08, 0.35, 0.14, 0.11, 0.16, 0.16],
|
||||||
|
3: [0.07, 0.3, 0.12, 0.1, 0.14, 0.14, 0.1],
|
||||||
|
4: [0.12, 0.06, 0.2, 0.1, 0.1, 0.12, 0.12, 0.1]
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a task table for display
|
* Create a task table for display
|
||||||
*/
|
*/
|
||||||
export function createTaskTable(
|
export function createTaskTable(
|
||||||
tasks: (Task | Subtask)[],
|
tasks: TaskTableItem[],
|
||||||
options?: {
|
options?: {
|
||||||
showSubtasks?: boolean;
|
showSubtasks?: boolean;
|
||||||
showComplexity?: boolean;
|
showComplexity?: boolean;
|
||||||
showDependencies?: boolean;
|
showDependencies?: boolean;
|
||||||
|
showBlocks?: boolean;
|
||||||
|
showTag?: boolean;
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
const {
|
const {
|
||||||
showSubtasks = false,
|
showSubtasks = false,
|
||||||
showComplexity = false,
|
showComplexity = false,
|
||||||
showDependencies = true
|
showDependencies = true,
|
||||||
|
showBlocks = false,
|
||||||
|
showTag = false
|
||||||
} = options || {};
|
} = options || {};
|
||||||
|
|
||||||
// Calculate dynamic column widths based on terminal width
|
// Calculate dynamic column widths based on terminal width
|
||||||
const tableWidth = getBoxWidth(0.9, 100);
|
const tableWidth = getBoxWidth(0.9, 100);
|
||||||
// Adjust column widths to better match the original layout
|
|
||||||
const baseColWidths = showComplexity
|
|
||||||
? [
|
|
||||||
Math.floor(tableWidth * 0.1),
|
|
||||||
Math.floor(tableWidth * 0.4),
|
|
||||||
Math.floor(tableWidth * 0.15),
|
|
||||||
Math.floor(tableWidth * 0.1),
|
|
||||||
Math.floor(tableWidth * 0.2),
|
|
||||||
Math.floor(tableWidth * 0.1)
|
|
||||||
] // ID, Title, Status, Priority, Dependencies, Complexity
|
|
||||||
: [
|
|
||||||
Math.floor(tableWidth * 0.08),
|
|
||||||
Math.floor(tableWidth * 0.4),
|
|
||||||
Math.floor(tableWidth * 0.18),
|
|
||||||
Math.floor(tableWidth * 0.12),
|
|
||||||
Math.floor(tableWidth * 0.2)
|
|
||||||
]; // ID, Title, Status, Priority, Dependencies
|
|
||||||
|
|
||||||
const headers = [
|
// Count optional columns and get corresponding width ratios
|
||||||
|
const optionalCols =
|
||||||
|
(showTag ? 1 : 0) +
|
||||||
|
(showDependencies ? 1 : 0) +
|
||||||
|
(showBlocks ? 1 : 0) +
|
||||||
|
(showComplexity ? 1 : 0);
|
||||||
|
|
||||||
|
const ratios = COLUMN_WIDTH_RATIOS[optionalCols] || COLUMN_WIDTH_RATIOS[4];
|
||||||
|
const baseColWidths = ratios.map((ratio) => Math.floor(tableWidth * ratio));
|
||||||
|
|
||||||
|
// Build headers and column widths dynamically
|
||||||
|
const headers: string[] = [];
|
||||||
|
const colWidths: number[] = [];
|
||||||
|
let colIndex = 0;
|
||||||
|
|
||||||
|
if (showTag) {
|
||||||
|
headers.push(chalk.blue.bold('Tag'));
|
||||||
|
colWidths.push(baseColWidths[colIndex++]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core columns: ID, Title, Status, Priority
|
||||||
|
headers.push(
|
||||||
chalk.blue.bold('ID'),
|
chalk.blue.bold('ID'),
|
||||||
chalk.blue.bold('Title'),
|
chalk.blue.bold('Title'),
|
||||||
chalk.blue.bold('Status'),
|
chalk.blue.bold('Status'),
|
||||||
chalk.blue.bold('Priority')
|
chalk.blue.bold('Priority')
|
||||||
];
|
);
|
||||||
const colWidths = baseColWidths.slice(0, 4);
|
colWidths.push(...baseColWidths.slice(colIndex, colIndex + 4));
|
||||||
|
colIndex += 4;
|
||||||
|
|
||||||
if (showDependencies) {
|
if (showDependencies) {
|
||||||
headers.push(chalk.blue.bold('Dependencies'));
|
headers.push(chalk.blue.bold('Dependencies'));
|
||||||
colWidths.push(baseColWidths[4]);
|
colWidths.push(baseColWidths[colIndex++]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBlocks) {
|
||||||
|
headers.push(chalk.blue.bold('Blocks'));
|
||||||
|
colWidths.push(baseColWidths[colIndex++]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showComplexity) {
|
if (showComplexity) {
|
||||||
headers.push(chalk.blue.bold('Complexity'));
|
headers.push(chalk.blue.bold('Complexity'));
|
||||||
colWidths.push(baseColWidths[5] || 12);
|
colWidths.push(baseColWidths[colIndex] || 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = new Table({
|
const table = new Table({
|
||||||
@@ -79,17 +115,27 @@ export function createTaskTable(
|
|||||||
});
|
});
|
||||||
|
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
const row: string[] = [
|
const row: string[] = [];
|
||||||
|
|
||||||
|
// Tag goes first when showing all tags
|
||||||
|
if (showTag) {
|
||||||
|
row.push(chalk.magenta(task.tagName || '-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core columns: ID, Title, Status, Priority
|
||||||
|
// Title column index depends on whether tag is shown
|
||||||
|
const titleColIndex = showTag ? 2 : 1;
|
||||||
|
row.push(
|
||||||
chalk.cyan(task.id.toString()),
|
chalk.cyan(task.id.toString()),
|
||||||
truncate(task.title, colWidths[1] - 3),
|
truncate(task.title, colWidths[titleColIndex] - 3),
|
||||||
getStatusWithColor(task.status, true), // Use table version
|
getStatusWithColor(task.status, true), // Use table version
|
||||||
getPriorityWithColor(task.priority)
|
getPriorityWithColor(task.priority)
|
||||||
];
|
);
|
||||||
|
|
||||||
if (showDependencies) {
|
if (showDependencies) {
|
||||||
// For table display, show simple format without status icons
|
// For table display, show simple format without status icons
|
||||||
if (!task.dependencies || task.dependencies.length === 0) {
|
if (!task.dependencies || task.dependencies.length === 0) {
|
||||||
row.push(chalk.gray('None'));
|
row.push(chalk.gray('-'));
|
||||||
} else {
|
} else {
|
||||||
row.push(
|
row.push(
|
||||||
chalk.cyan(task.dependencies.map((d) => String(d)).join(', '))
|
chalk.cyan(task.dependencies.map((d) => String(d)).join(', '))
|
||||||
@@ -97,6 +143,21 @@ export function createTaskTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBlocks) {
|
||||||
|
// Show tasks that depend on this one
|
||||||
|
if (!task.blocks || task.blocks.length === 0) {
|
||||||
|
row.push(chalk.gray('-'));
|
||||||
|
} else {
|
||||||
|
// Gray out blocks for completed tasks (no longer blocking)
|
||||||
|
const blocksText = task.blocks.join(', ');
|
||||||
|
row.push(
|
||||||
|
isTaskComplete(task.status)
|
||||||
|
? chalk.gray(blocksText)
|
||||||
|
: chalk.yellow(blocksText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showComplexity) {
|
if (showComplexity) {
|
||||||
// Show complexity score from report if available
|
// Show complexity score from report if available
|
||||||
if (typeof task.complexity === 'number') {
|
if (typeof task.complexity === 'number') {
|
||||||
@@ -111,23 +172,38 @@ export function createTaskTable(
|
|||||||
// Add subtasks if requested
|
// Add subtasks if requested
|
||||||
if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
|
if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
|
||||||
task.subtasks.forEach((subtask) => {
|
task.subtasks.forEach((subtask) => {
|
||||||
const subRow: string[] = [
|
const subRow: string[] = [];
|
||||||
|
|
||||||
|
// Tag goes first when showing all tags
|
||||||
|
if (showTag) {
|
||||||
|
// Subtasks inherit parent's tag, just show dash
|
||||||
|
subRow.push(chalk.gray('-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core subtask columns: ID, Title, Status, Priority
|
||||||
|
const subTitleColIndex = showTag ? 2 : 1;
|
||||||
|
subRow.push(
|
||||||
chalk.gray(` └─ ${subtask.id}`),
|
chalk.gray(` └─ ${subtask.id}`),
|
||||||
chalk.gray(truncate(subtask.title, colWidths[1] - 6)),
|
chalk.gray(truncate(subtask.title, colWidths[subTitleColIndex] - 6)),
|
||||||
chalk.gray(getStatusWithColor(subtask.status, true)),
|
chalk.gray(getStatusWithColor(subtask.status, true)),
|
||||||
chalk.gray(subtask.priority || DEFAULT_PRIORITY)
|
chalk.gray(subtask.priority || DEFAULT_PRIORITY)
|
||||||
];
|
);
|
||||||
|
|
||||||
if (showDependencies) {
|
if (showDependencies) {
|
||||||
subRow.push(
|
subRow.push(
|
||||||
chalk.gray(
|
chalk.gray(
|
||||||
subtask.dependencies && subtask.dependencies.length > 0
|
subtask.dependencies && subtask.dependencies.length > 0
|
||||||
? subtask.dependencies.map((dep) => String(dep)).join(', ')
|
? subtask.dependencies.map((dep) => String(dep)).join(', ')
|
||||||
: 'None'
|
: '-'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBlocks) {
|
||||||
|
// Subtasks don't typically have blocks, show dash
|
||||||
|
subRow.push(chalk.gray('-'));
|
||||||
|
}
|
||||||
|
|
||||||
if (showComplexity) {
|
if (showComplexity) {
|
||||||
const complexityDisplay =
|
const complexityDisplay =
|
||||||
typeof subtask.complexity === 'number'
|
typeof subtask.complexity === 'number'
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createTask, createTasksFile } from '@tm/core/testing';
|
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getCliBinPath } from '../../helpers/test-utils.js';
|
import { getCliBinPath } from '../../helpers/test-utils.js';
|
||||||
|
|
||||||
|
// Increase hook timeout for this file - init command can be slow in CI
|
||||||
|
vi.setConfig({ hookTimeout: 30000 });
|
||||||
|
|
||||||
// Capture initial working directory at module load time
|
// Capture initial working directory at module load time
|
||||||
const initialCwd = process.cwd();
|
const initialCwd = process.cwd();
|
||||||
|
|
||||||
@@ -144,13 +147,6 @@ describe('loop command', () => {
|
|||||||
expect(output).toContain('--tag');
|
expect(output).toContain('--tag');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show --json option in help', () => {
|
|
||||||
const { output, exitCode } = runHelp();
|
|
||||||
|
|
||||||
expect(exitCode).toBe(0);
|
|
||||||
expect(output).toContain('--json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show --progress-file option in help', () => {
|
it('should show --progress-file option in help', () => {
|
||||||
const { output, exitCode } = runHelp();
|
const { output, exitCode } = runHelp();
|
||||||
|
|
||||||
@@ -191,44 +187,6 @@ describe('loop command', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('option parsing', () => {
|
|
||||||
it('should accept valid iterations', () => {
|
|
||||||
// Command will fail when trying to run claude, but validation should pass
|
|
||||||
const { output } = runLoop('-n 5');
|
|
||||||
|
|
||||||
// Should NOT contain validation error for iterations
|
|
||||||
expect(output.toLowerCase()).not.toContain('invalid iterations');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom prompt preset', () => {
|
|
||||||
const { output } = runLoop('-p test-coverage');
|
|
||||||
|
|
||||||
// Should NOT contain validation error for prompt
|
|
||||||
expect(output.toLowerCase()).not.toContain('invalid prompt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept tag filter', () => {
|
|
||||||
const { output } = runLoop('-t feature');
|
|
||||||
|
|
||||||
// Should NOT contain validation error for tag
|
|
||||||
expect(output.toLowerCase()).not.toContain('invalid tag');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept progress-file option', () => {
|
|
||||||
const { output } = runLoop('--progress-file /tmp/test-progress.txt');
|
|
||||||
|
|
||||||
// Should NOT contain validation error for progress-file
|
|
||||||
expect(output.toLowerCase()).not.toContain('invalid progress');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept multiple options together', () => {
|
|
||||||
const { output } = runLoop('-n 3 -p default -t test');
|
|
||||||
|
|
||||||
// Should NOT contain validation errors
|
|
||||||
expect(output.toLowerCase()).not.toContain('invalid iterations');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('error messages', () => {
|
describe('error messages', () => {
|
||||||
it('should show helpful error for invalid iterations', () => {
|
it('should show helpful error for invalid iterations', () => {
|
||||||
const { output, exitCode } = runLoop('-n invalid');
|
const { output, exitCode } = runLoop('-n invalid');
|
||||||
@@ -239,23 +197,4 @@ describe('loop command', () => {
|
|||||||
expect(output.toLowerCase()).toContain('positive');
|
expect(output.toLowerCase()).toContain('positive');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('project detection', () => {
|
|
||||||
it('should work in initialized project directory', () => {
|
|
||||||
// The project is already initialized in beforeEach
|
|
||||||
// Command will fail when trying to run claude, but project detection should work
|
|
||||||
const { output } = runLoop('-n 1');
|
|
||||||
|
|
||||||
// Should NOT contain "not a task-master project" or similar
|
|
||||||
expect(output.toLowerCase()).not.toContain('not initialized');
|
|
||||||
expect(output.toLowerCase()).not.toContain('no project');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept --project option for explicit path', () => {
|
|
||||||
const { output } = runLoop(`--project "${testDir}" -n 1`);
|
|
||||||
|
|
||||||
// Should NOT contain validation error for project path
|
|
||||||
expect(output.toLowerCase()).not.toContain('invalid project');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Unit tests for ListTasksCommand
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { TmCore } from '@tm/core';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock('@tm/core', () => ({
|
|
||||||
createTmCore: vi.fn(),
|
|
||||||
OUTPUT_FORMATS: ['text', 'json', 'compact'],
|
|
||||||
TASK_STATUSES: [
|
|
||||||
'pending',
|
|
||||||
'in-progress',
|
|
||||||
'done',
|
|
||||||
'review',
|
|
||||||
'deferred',
|
|
||||||
'cancelled'
|
|
||||||
],
|
|
||||||
STATUS_ICONS: {
|
|
||||||
pending: '⏳',
|
|
||||||
'in-progress': '🔄',
|
|
||||||
done: '✅',
|
|
||||||
review: '👀',
|
|
||||||
deferred: '⏸️',
|
|
||||||
cancelled: '❌'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../src/utils/project-root.js', () => ({
|
|
||||||
getProjectRoot: vi.fn((path?: string) => path || '/test/project')
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../src/utils/error-handler.js', () => ({
|
|
||||||
displayError: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../src/utils/display-helpers.js', () => ({
|
|
||||||
displayCommandHeader: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../src/ui/index.js', () => ({
|
|
||||||
calculateDependencyStatistics: vi.fn(() => ({ total: 0, blocked: 0 })),
|
|
||||||
calculateSubtaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })),
|
|
||||||
calculateTaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })),
|
|
||||||
displayDashboards: vi.fn(),
|
|
||||||
displayRecommendedNextTask: vi.fn(),
|
|
||||||
displaySuggestedNextSteps: vi.fn(),
|
|
||||||
getPriorityBreakdown: vi.fn(() => ({})),
|
|
||||||
getTaskDescription: vi.fn(() => 'Test description')
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../src/utils/ui.js', () => ({
|
|
||||||
createTaskTable: vi.fn(() => 'Table output'),
|
|
||||||
displayWarning: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ListTasksCommand } from '../../../src/commands/list.command.js';
|
|
||||||
|
|
||||||
describe('ListTasksCommand', () => {
|
|
||||||
let consoleLogSpy: any;
|
|
||||||
let mockTmCore: Partial<TmCore>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
mockTmCore = {
|
|
||||||
tasks: {
|
|
||||||
list: vi.fn().mockResolvedValue({
|
|
||||||
tasks: [{ id: '1', title: 'Test Task', status: 'pending' }],
|
|
||||||
total: 1,
|
|
||||||
filtered: 1,
|
|
||||||
storageType: 'json'
|
|
||||||
}),
|
|
||||||
getStorageType: vi.fn().mockReturnValue('json')
|
|
||||||
} as any,
|
|
||||||
config: {
|
|
||||||
getActiveTag: vi.fn().mockReturnValue('master')
|
|
||||||
} as any
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('JSON output format', () => {
|
|
||||||
it('should use JSON format when --json flag is set', async () => {
|
|
||||||
const command = new ListTasksCommand();
|
|
||||||
|
|
||||||
// Mock the tmCore initialization
|
|
||||||
(command as any).tmCore = mockTmCore;
|
|
||||||
|
|
||||||
// Execute with --json flag
|
|
||||||
await (command as any).executeCommand({
|
|
||||||
json: true,
|
|
||||||
format: 'text' // Should be overridden by --json
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify JSON output was called
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalled();
|
|
||||||
const output = consoleLogSpy.mock.calls[0][0];
|
|
||||||
|
|
||||||
// Should be valid JSON
|
|
||||||
expect(() => JSON.parse(output)).not.toThrow();
|
|
||||||
|
|
||||||
const parsed = JSON.parse(output);
|
|
||||||
expect(parsed).toHaveProperty('tasks');
|
|
||||||
expect(parsed).toHaveProperty('metadata');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should override --format when --json is set', async () => {
|
|
||||||
const command = new ListTasksCommand();
|
|
||||||
(command as any).tmCore = mockTmCore;
|
|
||||||
|
|
||||||
await (command as any).executeCommand({
|
|
||||||
json: true,
|
|
||||||
format: 'compact' // Should be overridden
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should output JSON, not compact format
|
|
||||||
const output = consoleLogSpy.mock.calls[0][0];
|
|
||||||
expect(() => JSON.parse(output)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use specified format when --json is not set', async () => {
|
|
||||||
const command = new ListTasksCommand();
|
|
||||||
(command as any).tmCore = mockTmCore;
|
|
||||||
|
|
||||||
await (command as any).executeCommand({
|
|
||||||
format: 'compact'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should use compact format (not JSON)
|
|
||||||
const output = consoleLogSpy.mock.calls;
|
|
||||||
// In compact mode, output is not JSON
|
|
||||||
expect(output.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default to text format when neither flag is set', async () => {
|
|
||||||
const command = new ListTasksCommand();
|
|
||||||
(command as any).tmCore = mockTmCore;
|
|
||||||
|
|
||||||
await (command as any).executeCommand({});
|
|
||||||
|
|
||||||
// Should use text format (not JSON)
|
|
||||||
// If any console.log was called, verify it's not JSON
|
|
||||||
if (consoleLogSpy.mock.calls.length > 0) {
|
|
||||||
const output = consoleLogSpy.mock.calls[0][0];
|
|
||||||
// Text format output should not be parseable JSON
|
|
||||||
// or should be the table string we mocked
|
|
||||||
expect(
|
|
||||||
output === 'Table output' ||
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
JSON.parse(output);
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('format validation', () => {
|
|
||||||
it('should accept valid formats', () => {
|
|
||||||
const command = new ListTasksCommand();
|
|
||||||
|
|
||||||
expect((command as any).validateOptions({ format: 'text' })).toBe(true);
|
|
||||||
expect((command as any).validateOptions({ format: 'json' })).toBe(true);
|
|
||||||
expect((command as any).validateOptions({ format: 'compact' })).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid formats', () => {
|
|
||||||
const consoleErrorSpy = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const command = new ListTasksCommand();
|
|
||||||
|
|
||||||
expect((command as any).validateOptions({ format: 'invalid' })).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Invalid format: invalid')
|
|
||||||
);
|
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,10 +5,14 @@
|
|||||||
import type { TmCore } from '@tm/core';
|
import type { TmCore } from '@tm/core';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies - use partial mock to keep TaskIdSchema and other exports
|
||||||
vi.mock('@tm/core', () => ({
|
vi.mock('@tm/core', async (importOriginal) => {
|
||||||
createTmCore: vi.fn()
|
const actual = await importOriginal<typeof import('@tm/core')>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
createTmCore: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../../src/utils/project-root.js', () => ({
|
vi.mock('../../../src/utils/project-root.js', () => ({
|
||||||
getProjectRoot: vi.fn((path?: string) => path || '/test/project')
|
getProjectRoot: vi.fn((path?: string) => path || '/test/project')
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "task-master-ai",
|
"name": "task-master-ai",
|
||||||
"version": "0.41.0",
|
"version": "0.40.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "task-master-ai",
|
"name": "task-master-ai",
|
||||||
"version": "0.41.0",
|
"version": "0.40.1",
|
||||||
"license": "MIT WITH Commons-Clause",
|
"license": "MIT WITH Commons-Clause",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
|||||||
@@ -204,6 +204,17 @@ export {
|
|||||||
type GenerateTaskFilesResult
|
type GenerateTaskFilesResult
|
||||||
} from './modules/tasks/services/task-file-generator.service.js';
|
} from './modules/tasks/services/task-file-generator.service.js';
|
||||||
|
|
||||||
|
// Task filtering utilities
|
||||||
|
export {
|
||||||
|
buildBlocksMap,
|
||||||
|
filterReadyTasks,
|
||||||
|
filterBlockingTasks,
|
||||||
|
ACTIONABLE_STATUSES,
|
||||||
|
type TaskWithBlocks,
|
||||||
|
type InvalidDependency,
|
||||||
|
type BuildBlocksMapResult
|
||||||
|
} from './modules/tasks/utils/index.js';
|
||||||
|
|
||||||
// Integration - Advanced
|
// Integration - Advanced
|
||||||
export {
|
export {
|
||||||
ExportService,
|
ExportService,
|
||||||
|
|||||||
6
packages/tm-core/src/modules/tasks/utils/index.ts
Normal file
6
packages/tm-core/src/modules/tasks/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Task utility exports
|
||||||
|
* Re-exports task filtering and analysis utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './task-filters.js';
|
||||||
168
packages/tm-core/src/modules/tasks/utils/task-filters.ts
Normal file
168
packages/tm-core/src/modules/tasks/utils/task-filters.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Task filtering utilities for dependency and readiness analysis
|
||||||
|
* Business logic for filtering tasks by actionable status, dependencies, and blocking relationships
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Task, TaskStatus } from '../../../common/types/index.js';
|
||||||
|
import {
|
||||||
|
TASK_STATUSES,
|
||||||
|
isTaskComplete
|
||||||
|
} from '../../../common/constants/index.js';
|
||||||
|
import { getLogger } from '../../../common/logger/index.js';
|
||||||
|
|
||||||
|
const logger = getLogger('TaskFilters');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task with blocks field (inverse of dependencies)
|
||||||
|
* A task's blocks array contains IDs of tasks that depend on it
|
||||||
|
*/
|
||||||
|
export type TaskWithBlocks = Task & { blocks: string[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses that are actionable (not deferred, blocked, or terminal)
|
||||||
|
* Tasks with these statuses can be worked on when dependencies are satisfied
|
||||||
|
*/
|
||||||
|
export const ACTIONABLE_STATUSES: readonly TaskStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'in-progress',
|
||||||
|
'review'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid dependency reference (task depends on non-existent task)
|
||||||
|
*/
|
||||||
|
export interface InvalidDependency {
|
||||||
|
/** ID of the task with the invalid dependency */
|
||||||
|
taskId: string;
|
||||||
|
/** ID of the non-existent dependency */
|
||||||
|
depId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of building the blocks map with validation information
|
||||||
|
*/
|
||||||
|
export interface BuildBlocksMapResult {
|
||||||
|
/** Map of task ID -> array of task IDs that depend on it */
|
||||||
|
blocksMap: Map<string, string[]>;
|
||||||
|
/** Array of invalid dependency references (dependencies to non-existent tasks) */
|
||||||
|
invalidDependencies: InvalidDependency[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of task ID -> array of task IDs that depend on it (blocks)
|
||||||
|
* This is the inverse of the dependencies relationship
|
||||||
|
*
|
||||||
|
* Also validates dependencies and returns any references to non-existent tasks.
|
||||||
|
*
|
||||||
|
* @param tasks - Array of tasks to analyze
|
||||||
|
* @returns Object containing the blocks map and any invalid dependency references
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const tasks = [
|
||||||
|
* { id: '1', dependencies: [] },
|
||||||
|
* { id: '2', dependencies: ['1'] },
|
||||||
|
* { id: '3', dependencies: ['1', '2'] }
|
||||||
|
* ];
|
||||||
|
* const { blocksMap, invalidDependencies } = buildBlocksMap(tasks);
|
||||||
|
* // blocksMap.get('1') => ['2', '3'] // Task 1 blocks tasks 2 and 3
|
||||||
|
* // blocksMap.get('2') => ['3'] // Task 2 blocks task 3
|
||||||
|
* // blocksMap.get('3') => [] // Task 3 blocks nothing
|
||||||
|
* // invalidDependencies => [] // No invalid deps in this example
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function buildBlocksMap(tasks: Task[]): BuildBlocksMapResult {
|
||||||
|
const blocksMap = new Map<string, string[]>(
|
||||||
|
tasks.map((task) => [String(task.id), []])
|
||||||
|
);
|
||||||
|
const invalidDependencies: InvalidDependency[] = [];
|
||||||
|
|
||||||
|
// For each task, add it to the blocks list of each of its dependencies
|
||||||
|
for (const task of tasks) {
|
||||||
|
for (const depId of task.dependencies ?? []) {
|
||||||
|
const depIdStr = String(depId);
|
||||||
|
const blocks = blocksMap.get(depIdStr);
|
||||||
|
if (blocks) {
|
||||||
|
blocks.push(String(task.id));
|
||||||
|
} else {
|
||||||
|
// Dependency references a non-existent task
|
||||||
|
invalidDependencies.push({
|
||||||
|
taskId: String(task.id),
|
||||||
|
depId: depIdStr
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocksMap, invalidDependencies };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to only tasks that are ready to work on
|
||||||
|
* A task is ready when:
|
||||||
|
* 1. It has an actionable status (pending, in-progress, or review)
|
||||||
|
* 2. All its dependencies are complete (done, completed, or cancelled)
|
||||||
|
*
|
||||||
|
* @param tasks - Array of tasks with blocks information
|
||||||
|
* @returns Filtered array of tasks that are ready to work on
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const tasks = [
|
||||||
|
* { id: '1', status: 'done', dependencies: [], blocks: ['2'] },
|
||||||
|
* { id: '2', status: 'pending', dependencies: ['1'], blocks: [] },
|
||||||
|
* { id: '3', status: 'pending', dependencies: ['2'], blocks: [] }
|
||||||
|
* ];
|
||||||
|
* const readyTasks = filterReadyTasks(tasks);
|
||||||
|
* // Returns only task 2: status is actionable and dependency '1' is done
|
||||||
|
* // Task 3 is not ready because dependency '2' is still pending
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function filterReadyTasks(tasks: TaskWithBlocks[]): TaskWithBlocks[] {
|
||||||
|
// Build set of completed task IDs for dependency checking
|
||||||
|
const completedIds = new Set<string>(
|
||||||
|
tasks.filter((t) => isTaskComplete(t.status)).map((t) => String(t.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
return tasks.filter((task) => {
|
||||||
|
// Validate status is a known value
|
||||||
|
if (!TASK_STATUSES.includes(task.status)) {
|
||||||
|
logger.warn(
|
||||||
|
`Task ${task.id} has unexpected status "${task.status}". Valid statuses are: ${TASK_STATUSES.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be in an actionable status (excludes deferred, blocked, done, cancelled)
|
||||||
|
if (!ACTIONABLE_STATUSES.includes(task.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready if no dependencies or all dependencies are completed
|
||||||
|
const deps = task.dependencies ?? [];
|
||||||
|
return deps.every((depId) => completedIds.has(String(depId)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to only tasks that block other tasks
|
||||||
|
* These are tasks that have at least one other task depending on them
|
||||||
|
*
|
||||||
|
* @param tasks - Array of tasks with blocks information
|
||||||
|
* @returns Filtered array of tasks that have dependents (block other tasks)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const tasks = [
|
||||||
|
* { id: '1', blocks: ['2', '3'] }, // Blocks tasks 2 and 3
|
||||||
|
* { id: '2', blocks: [] }, // Blocks nothing
|
||||||
|
* { id: '3', blocks: [] } // Blocks nothing
|
||||||
|
* ];
|
||||||
|
* const blockingTasks = filterBlockingTasks(tasks);
|
||||||
|
* // Returns only task 1 (the only task with non-empty blocks)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function filterBlockingTasks(
|
||||||
|
tasks: TaskWithBlocks[]
|
||||||
|
): TaskWithBlocks[] {
|
||||||
|
return tasks.filter((task) => task.blocks.length > 0);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
tryListTagsViaRemote,
|
tryListTagsViaRemote,
|
||||||
tryUseTagViaRemote
|
tryUseTagViaRemote
|
||||||
} from '@tm/bridge';
|
} from '@tm/bridge';
|
||||||
|
import { filterReadyTasks, isTaskComplete } from '@tm/core';
|
||||||
import { displayBanner, getStatusWithColor } from '../ui.js';
|
import { displayBanner, getStatusWithColor } from '../ui.js';
|
||||||
import {
|
import {
|
||||||
findProjectRoot,
|
findProjectRoot,
|
||||||
@@ -531,6 +532,7 @@ async function enhanceTagsWithMetadata(tasksPath, rawData, context = {}) {
|
|||||||
* @param {Object} options - Options object
|
* @param {Object} options - Options object
|
||||||
* @param {boolean} [options.showTaskCounts=true] - Whether to show task counts
|
* @param {boolean} [options.showTaskCounts=true] - Whether to show task counts
|
||||||
* @param {boolean} [options.showMetadata=false] - Whether to show metadata
|
* @param {boolean} [options.showMetadata=false] - Whether to show metadata
|
||||||
|
* @param {boolean} [options.ready=false] - Whether to filter to only tags with ready tasks
|
||||||
* @param {Object} context - Context object containing session and projectRoot
|
* @param {Object} context - Context object containing session and projectRoot
|
||||||
* @param {string} [context.projectRoot] - Project root path
|
* @param {string} [context.projectRoot] - Project root path
|
||||||
* @param {Object} [context.mcpLog] - MCP logger object (optional)
|
* @param {Object} [context.mcpLog] - MCP logger object (optional)
|
||||||
@@ -544,7 +546,11 @@ async function tags(
|
|||||||
outputFormat = 'text'
|
outputFormat = 'text'
|
||||||
) {
|
) {
|
||||||
const { mcpLog, projectRoot } = context;
|
const { mcpLog, projectRoot } = context;
|
||||||
const { showTaskCounts = true, showMetadata = false } = options;
|
const {
|
||||||
|
showTaskCounts = true,
|
||||||
|
showMetadata = false,
|
||||||
|
ready = false
|
||||||
|
} = options;
|
||||||
|
|
||||||
// Create a consistent logFn object regardless of context
|
// Create a consistent logFn object regardless of context
|
||||||
const logFn = mcpLog || {
|
const logFn = mcpLog || {
|
||||||
@@ -619,12 +625,16 @@ async function tags(
|
|||||||
const tasks = tagData.tasks || [];
|
const tasks = tagData.tasks || [];
|
||||||
const metadata = tagData.metadata || {};
|
const metadata = tagData.metadata || {};
|
||||||
|
|
||||||
|
// Use centralized filtering from @tm/core
|
||||||
|
// Note: filterReadyTasks expects TaskWithBlocks[] but only uses status/dependencies at runtime
|
||||||
|
const tasksWithBlocks = tasks.map((t) => ({ ...t, blocks: [] }));
|
||||||
|
const readyTasks = filterReadyTasks(tasksWithBlocks);
|
||||||
|
|
||||||
tagList.push({
|
tagList.push({
|
||||||
name: tagName,
|
name: tagName,
|
||||||
isCurrent: tagName === currentTag,
|
isCurrent: tagName === currentTag,
|
||||||
completedTasks: tasks.filter(
|
completedTasks: tasks.filter((t) => isTaskComplete(t.status)).length,
|
||||||
(t) => t.status === 'done' || t.status === 'completed'
|
readyTasks: readyTasks.length,
|
||||||
).length,
|
|
||||||
tasks: tasks || [],
|
tasks: tasks || [],
|
||||||
created: metadata.created || 'Unknown',
|
created: metadata.created || 'Unknown',
|
||||||
description: metadata.description || 'No description'
|
description: metadata.description || 'No description'
|
||||||
@@ -638,22 +648,32 @@ async function tags(
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
logFn.success(`Found ${tagList.length} tags`);
|
// Filter to only tags with ready tasks if --ready flag is set
|
||||||
|
let filteredTagList = tagList;
|
||||||
|
if (ready) {
|
||||||
|
filteredTagList = tagList.filter((tag) => tag.readyTasks > 0);
|
||||||
|
logFn.info(`Filtered to ${filteredTagList.length} tags with ready tasks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logFn.success(`Found ${filteredTagList.length} tags`);
|
||||||
|
|
||||||
// For JSON output, return structured data
|
// For JSON output, return structured data
|
||||||
if (outputFormat === 'json') {
|
if (outputFormat === 'json') {
|
||||||
return {
|
return {
|
||||||
tags: tagList,
|
tags: filteredTagList,
|
||||||
currentTag,
|
currentTag,
|
||||||
totalTags: tagList.length
|
totalTags: filteredTagList.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// For text output, display formatted table
|
// For text output, display formatted table
|
||||||
if (outputFormat === 'text') {
|
if (outputFormat === 'text') {
|
||||||
if (tagList.length === 0) {
|
if (filteredTagList.length === 0) {
|
||||||
|
const message = ready
|
||||||
|
? 'No tags with ready tasks found'
|
||||||
|
: 'No tags found';
|
||||||
console.log(
|
console.log(
|
||||||
boxen(chalk.yellow('No tags found'), {
|
boxen(chalk.yellow(message), {
|
||||||
padding: 1,
|
padding: 1,
|
||||||
borderColor: 'yellow',
|
borderColor: 'yellow',
|
||||||
borderStyle: 'round',
|
borderStyle: 'round',
|
||||||
@@ -667,7 +687,8 @@ async function tags(
|
|||||||
const headers = [chalk.cyan.bold('Tag Name')];
|
const headers = [chalk.cyan.bold('Tag Name')];
|
||||||
if (showTaskCounts) {
|
if (showTaskCounts) {
|
||||||
headers.push(chalk.cyan.bold('Tasks'));
|
headers.push(chalk.cyan.bold('Tasks'));
|
||||||
headers.push(chalk.cyan.bold('Completed'));
|
headers.push(chalk.cyan.bold('Ready'));
|
||||||
|
headers.push(chalk.cyan.bold('Done'));
|
||||||
}
|
}
|
||||||
if (showMetadata) {
|
if (showMetadata) {
|
||||||
headers.push(chalk.cyan.bold('Created'));
|
headers.push(chalk.cyan.bold('Created'));
|
||||||
@@ -680,16 +701,16 @@ async function tags(
|
|||||||
|
|
||||||
let colWidths;
|
let colWidths;
|
||||||
if (showMetadata) {
|
if (showMetadata) {
|
||||||
// With metadata: Tag Name, Tasks, Completed, Created, Description
|
// With metadata: Tag Name, Tasks, Ready, Done, Created, Description
|
||||||
const widths = [0.25, 0.1, 0.12, 0.15, 0.38];
|
const widths = [0.22, 0.08, 0.08, 0.08, 0.14, 0.38];
|
||||||
colWidths = widths.map((w, i) =>
|
colWidths = widths.map((w, i) =>
|
||||||
Math.max(Math.floor(usableWidth * w), i === 0 ? 15 : 8)
|
Math.max(Math.floor(usableWidth * w), i === 0 ? 15 : 6)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Without metadata: Tag Name, Tasks, Completed
|
// Without metadata: Tag Name, Tasks, Ready, Done
|
||||||
const widths = [0.7, 0.15, 0.15];
|
const widths = [0.6, 0.13, 0.13, 0.13];
|
||||||
colWidths = widths.map((w, i) =>
|
colWidths = widths.map((w, i) =>
|
||||||
Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 10)
|
Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 8)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +721,7 @@ async function tags(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add rows
|
// Add rows
|
||||||
tagList.forEach((tag) => {
|
filteredTagList.forEach((tag) => {
|
||||||
const row = [];
|
const row = [];
|
||||||
|
|
||||||
// Tag name with current indicator
|
// Tag name with current indicator
|
||||||
@@ -711,6 +732,11 @@ async function tags(
|
|||||||
|
|
||||||
if (showTaskCounts) {
|
if (showTaskCounts) {
|
||||||
row.push(chalk.white(tag.tasks.length.toString()));
|
row.push(chalk.white(tag.tasks.length.toString()));
|
||||||
|
row.push(
|
||||||
|
tag.readyTasks > 0
|
||||||
|
? chalk.yellow(tag.readyTasks.toString())
|
||||||
|
: chalk.gray('0')
|
||||||
|
);
|
||||||
row.push(chalk.green(tag.completedTasks.toString()));
|
row.push(chalk.green(tag.completedTasks.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,9 +769,9 @@ async function tags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags: tagList,
|
tags: filteredTagList,
|
||||||
currentTag,
|
currentTag,
|
||||||
totalTags: tagList.length
|
totalTags: filteredTagList.length
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logFn.error(`Error listing tags: ${error.message}`);
|
logFn.error(`Error listing tags: ${error.message}`);
|
||||||
|
|||||||
@@ -113,3 +113,180 @@ describe('Tag Management – writeJSON context preservation', () => {
|
|||||||
expect(tagNames).not.toContain('copy');
|
expect(tagNames).not.toContain('copy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Tag Management – ready tasks count', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count tasks with no dependencies as ready', async () => {
|
||||||
|
const data = {
|
||||||
|
master: {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'pending', dependencies: [] },
|
||||||
|
{ id: 2, title: 'Task 2', status: 'pending', dependencies: [] },
|
||||||
|
{ id: 3, title: 'Task 3', status: 'done', dependencies: [] }
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const result = await listTags(
|
||||||
|
TASKS_PATH,
|
||||||
|
{ showTaskCounts: true },
|
||||||
|
{ projectRoot: TEMP_DIR },
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const masterTag = result.tags.find((t) => t.name === 'master');
|
||||||
|
expect(masterTag.readyTasks).toBe(2); // 2 pending, 1 done (not ready)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count tasks with satisfied dependencies as ready', async () => {
|
||||||
|
const data = {
|
||||||
|
master: {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'done', dependencies: [] },
|
||||||
|
{ id: 2, title: 'Task 2', status: 'pending', dependencies: [1] }, // deps satisfied
|
||||||
|
{ id: 3, title: 'Task 3', status: 'pending', dependencies: [2] } // deps NOT satisfied
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const result = await listTags(
|
||||||
|
TASKS_PATH,
|
||||||
|
{ showTaskCounts: true },
|
||||||
|
{ projectRoot: TEMP_DIR },
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const masterTag = result.tags.find((t) => t.name === 'master');
|
||||||
|
expect(masterTag.readyTasks).toBe(1); // only task 2 is ready
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude deferred and blocked tasks from ready count', async () => {
|
||||||
|
const data = {
|
||||||
|
master: {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'pending', dependencies: [] },
|
||||||
|
{ id: 2, title: 'Task 2', status: 'deferred', dependencies: [] },
|
||||||
|
{ id: 3, title: 'Task 3', status: 'blocked', dependencies: [] },
|
||||||
|
{ id: 4, title: 'Task 4', status: 'in-progress', dependencies: [] },
|
||||||
|
{ id: 5, title: 'Task 5', status: 'review', dependencies: [] }
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const result = await listTags(
|
||||||
|
TASKS_PATH,
|
||||||
|
{ showTaskCounts: true },
|
||||||
|
{ projectRoot: TEMP_DIR },
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const masterTag = result.tags.find((t) => t.name === 'master');
|
||||||
|
// Only pending, in-progress, review are actionable
|
||||||
|
expect(masterTag.readyTasks).toBe(3); // tasks 1, 4, 5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tag Management – --ready filter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out tags with no ready tasks when --ready is set', async () => {
|
||||||
|
const data = {
|
||||||
|
'has-ready': {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'pending', dependencies: [] }
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
},
|
||||||
|
'no-ready': {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'done', dependencies: [] },
|
||||||
|
{ id: 2, title: 'Task 2', status: 'deferred', dependencies: [] }
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
},
|
||||||
|
'all-blocked': {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'blocked', dependencies: [] }
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const result = await listTags(
|
||||||
|
TASKS_PATH,
|
||||||
|
{ showTaskCounts: true, ready: true },
|
||||||
|
{ projectRoot: TEMP_DIR },
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.tags.length).toBe(1);
|
||||||
|
expect(result.tags[0].name).toBe('has-ready');
|
||||||
|
expect(result.totalTags).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include all tags when --ready is not set', async () => {
|
||||||
|
const data = {
|
||||||
|
'has-ready': {
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, title: 'Task 1', status: 'pending', dependencies: [] }
|
||||||
|
],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
},
|
||||||
|
'no-ready': {
|
||||||
|
tasks: [{ id: 1, title: 'Task 1', status: 'done', dependencies: [] }],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const result = await listTags(
|
||||||
|
TASKS_PATH,
|
||||||
|
{ showTaskCounts: true, ready: false },
|
||||||
|
{ projectRoot: TEMP_DIR },
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.tags.length).toBe(2);
|
||||||
|
expect(result.totalTags).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty list when no tags have ready tasks', async () => {
|
||||||
|
const data = {
|
||||||
|
'all-done': {
|
||||||
|
tasks: [{ id: 1, title: 'Task 1', status: 'done', dependencies: [] }],
|
||||||
|
metadata: { created: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const result = await listTags(
|
||||||
|
TASKS_PATH,
|
||||||
|
{ showTaskCounts: true, ready: true },
|
||||||
|
{ projectRoot: TEMP_DIR },
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.tags.length).toBe(0);
|
||||||
|
expect(result.totalTags).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user