diff --git a/AGENTS.md b/AGENTS.md index f03a5998..1909b3da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,7 @@ wl list --json wl list -n 5 --json # List items filtered by status (open, in-progress, closed, etc.) wl list --status open --json +wl list --status open,in-progress --json # comma-separated: matches any listed status # List items filtered by priority (critical, high, medium, low) wl list --priority high --json # List items filtered by comma-separated tags diff --git a/CLI.md b/CLI.md index 5041ca21..2e5c8dfa 100644 --- a/CLI.md +++ b/CLI.md @@ -450,6 +450,9 @@ Examples: ```sh wl list wl list -s open -p high +wl list -s open,in-progress # status is open OR in-progress +wl list --status open,completed,blocked +wl list -s open,in-progress --stage in_review # status AND stage filters wl search "signup" wl -F concise list -s in-progress wl --json list -s open --tags backlog diff --git a/EXAMPLES.md b/EXAMPLES.md index 2861df5c..210e0499 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -29,6 +29,10 @@ worklog list --parent null # Filter by status worklog list -s in-progress +# Filter by multiple statuses (comma-separated, OR semantics) +worklog list -s open,in-progress +worklog list --status open,completed,blocked + # Filter by priority worklog list -p high @@ -37,6 +41,9 @@ worklog list --tags "backend,api" # Combine filters worklog list -s open -p high + +# Combine multi-status with stage filter (AND semantics) +worklog list -s open,in-progress --stage in_review ``` ### Viewing Work Items diff --git a/src/api.ts b/src/api.ts index 216d78a7..d08c4e19 100644 --- a/src/api.ts +++ b/src/api.ts @@ -157,7 +157,8 @@ export function createAPI(db: WorklogDatabase) { const query: WorkItemQuery = {}; if (req.query.status) { - query.status = req.query.status as WorkItemStatus; + const raw = Array.isArray(req.query.status) ? (req.query.status as string[]).join(',') : req.query.status as string; + query.status = raw.split(',').map(s => s.trim()) as WorkItemStatus[]; } if (req.query.priority) { query.priority = req.query.priority as WorkItemPriority; @@ -362,7 +363,8 @@ export function createAPI(db: WorklogDatabase) { const query: WorkItemQuery = {}; if (req.query.status) { - query.status = req.query.status as WorkItemStatus; + const raw = Array.isArray(req.query.status) ? (req.query.status as string[]).join(',') : req.query.status as string; + query.status = raw.split(',').map(s => s.trim()) as WorkItemStatus[]; } if (req.query.priority) { query.priority = req.query.priority as WorkItemPriority; diff --git a/src/cli-types.ts b/src/cli-types.ts index a9e7d2b6..c3750756 100644 --- a/src/cli-types.ts +++ b/src/cli-types.ts @@ -44,7 +44,7 @@ export interface CreateOptions { } export interface ListOptions { - status?: WorkItemStatus; + status?: string; priority?: WorkItemPriority; parent?: string; tags?: string; diff --git a/src/commands/in-progress.ts b/src/commands/in-progress.ts index f88a3477..9af24ebf 100644 --- a/src/commands/in-progress.ts +++ b/src/commands/in-progress.ts @@ -20,7 +20,7 @@ export default function register(ctx: PluginContext): void { utils.requireInitialized(); const db = utils.getDatabase(options.prefix); - const query: WorkItemQuery = { status: 'in-progress' as WorkItemStatus }; + const query: WorkItemQuery = { status: ['in-progress' as WorkItemStatus] }; if (options.assignee) { query.assignee = options.assignee; } diff --git a/src/commands/list.ts b/src/commands/list.ts index b2c46fad..6e4139be 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -30,7 +30,18 @@ export default function register(ctx: PluginContext): void { const db = utils.getDatabase(options?.prefix); const query: WorkItemQuery = {}; - if (options.status) query.status = options.status as WorkItemStatus; + if (options.status) { + const validStatuses = ['open', 'in-progress', 'completed', 'blocked', 'deleted', 'input-needed']; + const statuses = options.status.split(',').map(s => s.trim()); + for (const s of statuses) { + const normalized = s.replace(/_/g, '-'); + if (!validStatuses.includes(normalized)) { + output.error(`Invalid status value: ${s}. Valid values: ${validStatuses.join(', ')}`, { success: false, error: 'invalid-arg' }); + process.exit(1); + } + } + query.status = statuses.map(s => s.replace(/_/g, '-') as WorkItemStatus); + } if (options.priority) query.priority = options.priority as WorkItemPriority; if (options.parent) { const normalizedParentId = utils.normalizeCliId(options.parent, options.prefix) || options.parent; diff --git a/src/database.ts b/src/database.ts index b5c8bf9b..4e2a3ad7 100644 --- a/src/database.ts +++ b/src/database.ts @@ -813,11 +813,11 @@ export class WorklogDatabase { let items = this.store.getAllWorkItems(); if (query) { - if (query.status) { + if (query.status && query.status.length > 0) { // Status values are normalized to hyphenated form on write/import, - // so we only need to normalize the query parameter for user input. - const normalizedQueryStatus = normalizeStatusValue(query.status) ?? query.status; - items = items.filter(item => item.status === normalizedQueryStatus); + // so we normalize each query value for comparison. + const normalizedStatuses = query.status.map(s => normalizeStatusValue(s) ?? s); + items = items.filter(item => normalizedStatuses.includes(item.status)); } if (query.priority) { items = items.filter(item => item.priority === query.priority); diff --git a/src/tui/controller.ts b/src/tui/controller.ts index bc34522b..eeb8ca27 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -286,7 +286,7 @@ export class TuiController { }; const query: Partial> = {}; - if (options.inProgress) query.status = 'in-progress'; + if (options.inProgress) query.status = ['in-progress']; const allItems: Item[] = listWorkItemsSafely(query, [], 'initial-load').items; const showClosed = Boolean(options.all); @@ -3048,7 +3048,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) { const selected = getSelectedItem(); const selectedId = selected?.id; const query: any = {}; - if (status) query.status = status; + if (status) query.status = [status]; if (needsReviewFilter !== null) query.needsProducerReview = needsReviewFilter; const listed = listWorkItemsSafely(query, state.items.slice(), 'refresh-list'); if (listed.busy) { diff --git a/src/tui/wl-db-adapter.ts b/src/tui/wl-db-adapter.ts index de169a11..128e0fa3 100644 --- a/src/tui/wl-db-adapter.ts +++ b/src/tui/wl-db-adapter.ts @@ -147,12 +147,8 @@ function buildListArgs(query: Record): string[] { const args: string[] = []; // Map common query fields to wl list flags if (query.status) { - // Support array of statuses - if (Array.isArray(query.status)) { - query.status.forEach((s: string) => args.push('--status', s)); - } else { - args.push('--status', String(query.status)); - } + const raw = Array.isArray(query.status) ? (query.status as string[]).join(',') : String(query.status); + args.push('--status', raw); } if (query.inProgress === true) { args.push('--in-progress'); diff --git a/src/types.ts b/src/types.ts index ff260d30..149a9842 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,7 +114,7 @@ export interface UpdateWorkItemInput { audit?: WorkItemAudit; } export interface WorkItemQuery { - status?: WorkItemStatus; + status?: WorkItemStatus[]; priority?: WorkItemPriority; parentId?: string | null; tags?: string[]; diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 42b7bc68..6389e6e4 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -174,6 +174,7 @@ wl list --json wl list -n 5 --json # List items filtered by status (open, in-progress, closed, etc.) wl list --status open --json +wl list --status open,in-progress --json # comma-separated: matches any listed status # List items filtered by priority (critical, high, medium, low) wl list --priority high --json # List items filtered by comma-separated tags diff --git a/tests/cli/issue-status.test.ts b/tests/cli/issue-status.test.ts index 58dedaa2..54fc7d0c 100644 --- a/tests/cli/issue-status.test.ts +++ b/tests/cli/issue-status.test.ts @@ -178,6 +178,63 @@ describe('CLI Issue Status Tests', () => { expect(humanStdout).toContain('Found 1 work item'); }); + it('should filter by multiple comma-separated statuses', async () => { + const { stdout } = await execAsync(`tsx ${cliPath} --json list -s open,in-progress`); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItems).toHaveLength(2); + const statuses = result.workItems.map((item: any) => item.status); + expect(statuses).toContain('open'); + expect(statuses).toContain('in-progress'); + }); + + it('should filter by multiple statuses with --status open,completed', async () => { + const { stdout } = await execAsync(`tsx ${cliPath} --json list --status open,completed`); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItems).toHaveLength(2); + const statuses = result.workItems.map((item: any) => item.status); + expect(statuses).toContain('open'); + expect(statuses).toContain('completed'); + }); + + it('should combine comma-separated --status with --stage', async () => { + seedWorkItems(tempState.tempDir, [ + { title: 'Open In Progress', status: 'open', stage: 'in_progress' }, + { title: 'In Progress Review', status: 'in-progress', stage: 'in_review' }, + { title: 'Completed Done', status: 'completed', stage: 'done' }, + ]); + + // --status open,in-progress AND --stage in_review should return only items matching both + const { stdout } = await execAsync(`tsx ${cliPath} --json list --status open,in-progress --stage in_review`); + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItems).toHaveLength(1); + expect(result.workItems[0].title).toBe('In Progress Review'); + }); + + it('should return error for invalid status value', async () => { + try { + await execAsync(`tsx ${cliPath} --json list -s invalid_status`); + expect.fail('Should have thrown an error'); + } catch (error: any) { + const result = JSON.parse(error.stderr || '{}'); + expect(result.success).toBe(false); + } + }); + + it('should return error when any status in comma-separated list is invalid', async () => { + try { + await execAsync(`tsx ${cliPath} --json list -s open,invalid_status`); + expect.fail('Should have thrown an error'); + } catch (error: any) { + const result = JSON.parse(error.stderr || '{}'); + expect(result.success).toBe(false); + } + }); + it('should still hide completed items in human mode when no stage filter is set', async () => { // The default behavior (no --stage, no --status) should still hide completed items in human mode const { stdout: humanStdout } = await execAsync(`tsx ${cliPath} list`); diff --git a/tests/database.test.ts b/tests/database.test.ts index 9d2924e1..65bdc444 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -151,7 +151,7 @@ describe('WorklogDatabase', () => { it('should normalize status when querying with underscore form', () => { db.create({ title: 'Test', status: 'in-progress' }); // Query using underscore form — should still find the item - const results = db.list({ status: 'in_progress' as any }); + const results = db.list({ status: ['in_progress'] as any }); expect(results.length).toBe(1); expect(results[0].status).toBe('in-progress'); }); @@ -189,11 +189,19 @@ describe('WorklogDatabase', () => { }); it('should filter by status', () => { - const openItems = db.list({ status: 'open' }); + const openItems = db.list({ status: ['open'] }); expect(openItems).toHaveLength(2); openItems.forEach(item => expect(item.status).toBe('open')); }); + it('should filter by multiple statuses', () => { + const items = db.list({ status: ['open', 'completed'] }); + expect(items).toHaveLength(3); + const statuses = items.map(item => item.status); + expect(statuses.filter(s => s === 'open')).toHaveLength(2); + expect(statuses.filter(s => s === 'completed')).toHaveLength(1); + }); + it('should filter by priority', () => { const highPriorityItems = db.list({ priority: 'high' }); expect(highPriorityItems).toHaveLength(2); @@ -201,7 +209,7 @@ describe('WorklogDatabase', () => { }); it('should filter by status and priority', () => { - const items = db.list({ status: 'open', priority: 'high' }); + const items = db.list({ status: ['open'], priority: 'high' }); expect(items).toHaveLength(2); items.forEach(item => { expect(item.status).toBe('open'); @@ -209,6 +217,16 @@ describe('WorklogDatabase', () => { }); }); + it('should combine multiple statuses with priority', () => { + const items = db.list({ status: ['open', 'blocked'], priority: 'high' }); + expect(items).toHaveLength(2); + const statuses = items.map(item => item.status); + expect(statuses.filter(s => s === 'open')).toHaveLength(2); + items.forEach(item => { + expect(item.priority).toBe('high'); + }); + }); + it('should filter by tags', () => { const items = db.list({ tags: ['backend'] }); expect(items).toHaveLength(1); diff --git a/tests/sort-operations.test.ts b/tests/sort-operations.test.ts index 69826ce6..adcc272c 100644 --- a/tests/sort-operations.test.ts +++ b/tests/sort-operations.test.ts @@ -517,7 +517,7 @@ describe('Sort Operations', () => { const item2 = db.create({ title: 'Task 2', status: 'in-progress', sortIndex: 100 }); const item3 = db.create({ title: 'Task 3', status: 'open', sortIndex: 200 }); - const openItems = db.list({ status: 'open' }); + const openItems = db.list({ status: ['open'] }); expect(openItems).toHaveLength(2); // Check sortIndex values are preserved diff --git a/tests/tui/wl-db-adapter.test.ts b/tests/tui/wl-db-adapter.test.ts index 10b1028e..987e9d7c 100644 --- a/tests/tui/wl-db-adapter.test.ts +++ b/tests/tui/wl-db-adapter.test.ts @@ -142,7 +142,7 @@ describe('createWlDbAdapter', () => { it('uses wl subcommands and unwraps list/show/create/update envelopes', () => { const db = createWlDbAdapter(); - expect(db.list({ status: 'open', assignee: 'Map' })).toEqual([baseWorkItem]); + expect(db.list({ status: ['open'], assignee: 'Map' })).toEqual([baseWorkItem]); expect(db.get('WL-TEST-1')).toEqual(baseWorkItem); expect(db.create({ title: 'Created item', description: 'Created description' })).toEqual({ ...baseWorkItem,