From c273eedece6cd7b0a2193865b95bb4f3ecacc396 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 18:14:09 +0900 Subject: [PATCH 1/3] chore: ignore .impeccable plugin session cache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b32a40c..3d3358b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ artifacts/ # Asana CLI skill — eval scratch & evals may contain private workspace data skills/*-workspace/ skills/asana-cli/evals/ + +# Impeccable plugin session cache +.impeccable/ From 176e7171fc537714066a7927673612f99c3dcb96 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 18:14:10 +0900 Subject: [PATCH 2/3] feat(task): add field expansion and aggregation to task list - --fields pulls extra fields (completed, assignee, due_on, ...) into the listing via opt_fields, removing the need for per-task `task get` calls - --incomplete-only / --completed-only exclusive completion filters - --assignee none filters unassigned tasks; assignee filtering now also works for project listings (resolves "me" client-side) - --count and --group-by assignee|completed return precomputed aggregates - deprecate -c/--completed (help text now matches its actual behavior of excluding completed tasks) Closes #88 --- .../en/2.features/1.task-management.md | 48 +++- .../ko/2.features/1.task-management.md | 48 +++- src/commands/task.ts | 107 ++++++--- src/constants/errorIds.ts | 3 + src/lib/task-list-query.ts | 216 ++++++++++++++++++ src/types/index.ts | 6 + test/commands/task-list-scan.test.ts | 142 ++++++++++++ test/lib/task-list-query.test.ts | 168 ++++++++++++++ 8 files changed, 702 insertions(+), 36 deletions(-) create mode 100644 src/lib/task-list-query.ts create mode 100644 test/commands/task-list-scan.test.ts create mode 100644 test/lib/task-list-query.test.ts diff --git a/docs/content/en/2.features/1.task-management.md b/docs/content/en/2.features/1.task-management.md index 800d1a6..abef79c 100644 --- a/docs/content/en/2.features/1.task-management.md +++ b/docs/content/en/2.features/1.task-management.md @@ -149,12 +149,54 @@ View all tasks in a specific project: bun run dev task list -p PROJECT_ID ``` -### Include Completed Tasks +### Filter by Completion State -By default, only incomplete tasks are shown. Include completed tasks: +List only incomplete or only completed tasks: ```bash -bun run dev task list -a me -c -w WORKSPACE_ID +bun run dev task list -p PROJECT_ID --incomplete-only +bun run dev task list -p PROJECT_ID --completed-only +``` + +> `-c, --completed` is deprecated: despite its name it excludes completed tasks. Use `--incomplete-only` instead. + +### Filter by Assignee + +`-a` accepts `me`, a user GID, or `none` for unassigned tasks: + +```bash +bun run dev task list -p PROJECT_ID -a none +``` + +### Expand Fields + +Pull extra fields into the listing with `--fields` instead of one `task get` per task: + +```bash +bun run dev task list -p PROJECT_ID --fields completed,assignee,due_on +``` + +### Count and Group + +Return aggregates instead of rows: + +```bash +bun run dev task list -p PROJECT_ID --count +bun run dev task list -p PROJECT_ID --incomplete-only --group-by assignee --format json +``` + +Example output: + +```json +{ + "summary": { + "total": 12, + "groups": [ + { "assignee": "Alice", "count": 7 }, + { "assignee": "unassigned", "count": 5 } + ] + } +} ``` ### List Tasks for Another User diff --git a/docs/content/ko/2.features/1.task-management.md b/docs/content/ko/2.features/1.task-management.md index 39ba5c6..fe03fd1 100644 --- a/docs/content/ko/2.features/1.task-management.md +++ b/docs/content/ko/2.features/1.task-management.md @@ -149,12 +149,54 @@ View all tasks in a specific project: bun run dev task list -p PROJECT_ID ``` -### Include Completed Tasks +### 완료 상태로 필터링 -By default, only incomplete tasks are shown. Include completed tasks: +미완료 또는 완료 작업만 조회합니다: ```bash -bun run dev task list -a me -c -w WORKSPACE_ID +bun run dev task list -p PROJECT_ID --incomplete-only +bun run dev task list -p PROJECT_ID --completed-only +``` + +> `-c, --completed` 플래그는 폐기 예정입니다. 이름과 달리 완료된 작업을 제외하므로 `--incomplete-only`를 사용하세요. + +### 담당자로 필터링 + +`-a` 옵션은 `me`, 사용자 GID 외에 미할당 작업을 뜻하는 `none`도 지원합니다: + +```bash +bun run dev task list -p PROJECT_ID -a none +``` + +### 필드 확장 + +`--fields`로 목록에 추가 필드를 포함하면 작업마다 `task get`을 호출할 필요가 없습니다: + +```bash +bun run dev task list -p PROJECT_ID --fields completed,assignee,due_on +``` + +### 개수 집계 + +행 목록 대신 합계를 반환합니다: + +```bash +bun run dev task list -p PROJECT_ID --count +bun run dev task list -p PROJECT_ID --incomplete-only --group-by assignee --format json +``` + +출력 예시: + +```json +{ + "summary": { + "total": 12, + "groups": [ + { "assignee": "Alice", "count": 7 }, + { "assignee": "unassigned", "count": 5 } + ] + } +} ``` ### List Tasks for Another User diff --git a/src/commands/task.ts b/src/commands/task.ts index 9d22e9a..3b0f6cb 100644 --- a/src/commands/task.ts +++ b/src/commands/task.ts @@ -7,6 +7,17 @@ import { toTaskView } from '../lib/asana-views' import { emitError, emitResult } from '../lib/axi-output' import { loadConfig } from '../lib/config' import { handleAsanaError, isNotFoundError } from '../lib/error-handler' +import { + applyAssigneeFilter, + applyCompletionFilter, + buildOptFields, + effectiveColumns, + GROUP_BY_FIELDS, + needsClientAssigneeFilter, + parseTaskListQuery, + summarizeTasks, + toTaskRows, +} from '../lib/task-list-query' import { validateDateFormat, validateGid, validateUpdateFields, ValidationError } from '../lib/validators' import { formatOutput, getOutputFormat } from '../utils/formatter' import { createTaskCustomFieldCommand } from './custom-field' @@ -34,6 +45,33 @@ function failValidation(error: ValidationError, command: Command): never { process.exit(1) } +/** + * Fetch one listing page for `task list`. Assignee filtering happens + * server-side only in workspace mode with a concrete assignee; project + * listings and `--assignee none` filter client-side (clientAssignee=true). + */ +async function fetchTaskPage( + client: ReturnType, + options: TaskListOptions, + workspace: string | undefined, + params: Record, + clientAssignee: boolean, +): Promise { + if (options.project) { + const result = await client.tasks.findByProject(options.project, params) + return result.data || [] + } + if (options.assignee && !clientAssignee) { + const result = await client.tasks.findAll({ ...params, assignee: options.assignee, workspace }) + return result.data || [] + } + if (workspace) { + const result = await client.tasks.findAll({ ...params, workspace }) + return result.data || [] + } + throw new Error('Specify workspace, project, or assignee to list tasks') +} + export function createTaskCommand(): Command { const task = new Command('task') .description('Manage Asana tasks') @@ -115,49 +153,43 @@ export function createTaskCommand(): Command { task .command('list') .description('List tasks') - .option('-a, --assignee ', 'Filter by assignee (use "me" for current user)') + .option('-a, --assignee ', 'Filter by assignee ("me", "none" for unassigned, or user GID)') .option('-w, --workspace ', 'Workspace GID') .option('-p, --project ', 'Project GID') - .option('-c, --completed', 'Include completed tasks') + .option('-c, --completed', 'Deprecated: excludes completed tasks (use --incomplete-only)') + .option('--fields ', 'Extra fields per task, comma-separated (e.g. completed,assignee,due_on)') + .option('--completed-only', 'Only list completed tasks') + .option('--incomplete-only', 'Only list incomplete tasks') + .option('--count', 'Output only the task count') + .option('--group-by ', `Output counts grouped by field (${GROUP_BY_FIELDS.join('|')})`) .action(async (options: TaskListOptions, command: Command) => { // Declared outside try so the error handler can report it let workspace: string | undefined try { + const query = parseTaskListQuery(options) const client = getAsanaClient() const config = loadConfig() workspace = options.workspace || config?.workspace - let tasks: any - - if (options.project) { - tasks = await client.tasks.findByProject(options.project, { - completed_since: options.completed ? 'now' : undefined, - }) - } - else if (options.assignee) { - const assignee = options.assignee === 'me' ? 'me' : options.assignee - tasks = await client.tasks.findAll({ - assignee, - workspace, - completed_since: options.completed ? 'now' : undefined, - }) + const clientAssignee = needsClientAssigneeFilter(query, !!options.project) + const params: Record = {} + const optFields = buildOptFields(query, clientAssignee) + if (optFields) { + params.opt_fields = optFields } - else if (workspace) { - tasks = await client.tasks.findAll({ - workspace, - completed_since: options.completed ? 'now' : undefined, - }) - } - else { - throw new Error('Specify workspace, project, or assignee to list tasks') + // completed_since=now is the API's incomplete-only filter; the + // deprecated -c/--completed flag historically mapped to it too. + if (query.incompleteOnly || options.completed) { + params.completed_since = 'now' } - const taskList = tasks.data || [] + let taskList = await fetchTaskPage(client, options, workspace, params, clientAssignee) - if (taskList.length === 0) { - console.log(chalk.yellow('No tasks found')) - return + if (clientAssignee && query.assignee) { + const assignee = query.assignee === 'me' ? (await client.users.me()).gid : query.assignee + taskList = applyAssigneeFilter(taskList, assignee) } + taskList = applyCompletionFilter(taskList, query) // Resolve --format from the global options. Use getOutputFormat // (optsWithGlobals) rather than walking the parent chain by hand — the @@ -165,11 +197,26 @@ export function createTaskCommand(): Command { // here previously stopped one level short and silently ignored --format. const format = getOutputFormat(command) - // Format output based on selected format - const output = formatOutput({ tasks: taskList }, { format, colors: process.stdout.isTTY }) + if (query.count || query.groupBy) { + const summary = summarizeTasks(taskList, query.groupBy) + console.log(formatOutput({ summary }, { format, colors: process.stdout.isTTY })) + return + } + + if (taskList.length === 0) { + console.log(chalk.yellow('No tasks found')) + return + } + + const columns = effectiveColumns(query, clientAssignee) + const tasks = columns.length > 0 ? toTaskRows(taskList, columns) : taskList + const output = formatOutput({ tasks }, { format, colors: process.stdout.isTTY }) console.log(output) } catch (error) { + if (error instanceof ValidationError) { + failValidation(error, command) + } handleAsanaError(error, 'Task listing', { Workspace: workspace, Project: options.project, diff --git a/src/constants/errorIds.ts b/src/constants/errorIds.ts index d0f5c2f..5501530 100644 --- a/src/constants/errorIds.ts +++ b/src/constants/errorIds.ts @@ -24,6 +24,9 @@ export const ERROR_IDS = { INVALID_USER_IDENTIFIER: 'INVALID_USER_IDENTIFIER', INVALID_TAG_COLOR: 'INVALID_TAG_COLOR', INVALID_ASANA_URL: 'INVALID_ASANA_URL', + INVALID_FIELD_NAME: 'INVALID_FIELD_NAME', + INVALID_GROUP_BY: 'INVALID_GROUP_BY', + CONFLICTING_OPTIONS: 'CONFLICTING_OPTIONS', FILE_NOT_FOUND: 'FILE_NOT_FOUND', // Custom field errors diff --git a/src/lib/task-list-query.ts b/src/lib/task-list-query.ts new file mode 100644 index 0000000..6a91978 --- /dev/null +++ b/src/lib/task-list-query.ts @@ -0,0 +1,216 @@ +/** + * Query helpers for the scan-style `task list` options: `--fields`, the + * completion filters, `--assignee none`, `--count`, and `--group-by`. + * + * Without field expansion, any "count/filter tasks by X" question forces one + * `task get` per task (issue #88). These helpers let a single listing call + * carry the fields needed to filter and aggregate client-side. + * + * All functions here are pure; the command layer owns the API calls. + */ + +import type { TaskListOptions } from '../types' +import chalk from 'chalk' +import { ERROR_IDS } from '../constants/errorIds' +import { ValidationError } from './validators' + +export const GROUP_BY_FIELDS = ['assignee', 'completed'] as const +export type GroupByField = (typeof GROUP_BY_FIELDS)[number] + +export interface TaskListQuery { + fields: string[] + completedOnly: boolean + incompleteOnly: boolean + assignee?: string + count: boolean + groupBy?: GroupByField +} + +// Asana opt_fields tokens: dot-separated identifier segments (e.g. "due_on", +// "assignee.name"). Anything else is rejected before it reaches the API. +const FIELD_NAME_PATTERN = /^\w+(?:\.\w+)*$/ + +/** + * Validate and normalize the scan-related list options. + * @throws ValidationError on conflicting or malformed options + */ +export function parseTaskListQuery(options: TaskListOptions): TaskListQuery { + if (options.completedOnly && (options.incompleteOnly || options.completed)) { + console.error(chalk.red('✗ --completed-only cannot be combined with --incomplete-only or --completed')) + throw new ValidationError( + ERROR_IDS.CONFLICTING_OPTIONS, + '--completed-only conflicts with --incomplete-only/--completed', + { completedOnly: true, incompleteOnly: !!options.incompleteOnly, completed: !!options.completed }, + ) + } + + const fields = (options.fields ?? '') + .split(',') + .map(field => field.trim()) + .filter(field => field.length > 0) + + if (options.fields !== undefined && fields.length === 0) { + console.error(chalk.red('✗ --fields requires at least one field name')) + throw new ValidationError(ERROR_IDS.INVALID_FIELD_NAME, '--fields requires at least one field name', { + fields: options.fields, + }) + } + for (const field of fields) { + if (!FIELD_NAME_PATTERN.test(field)) { + console.error(chalk.red(`✗ Invalid field name: ${field}`)) + console.error(chalk.gray(' Expected identifiers like "completed", "due_on", "assignee"')) + throw new ValidationError(ERROR_IDS.INVALID_FIELD_NAME, `Invalid field name: ${field}`, { field }) + } + } + + let groupBy: GroupByField | undefined + if (options.groupBy !== undefined) { + if (!GROUP_BY_FIELDS.includes(options.groupBy as GroupByField)) { + console.error(chalk.red(`✗ Invalid --group-by field: ${options.groupBy}`)) + console.error(chalk.gray(` Supported: ${GROUP_BY_FIELDS.join(', ')}`)) + throw new ValidationError(ERROR_IDS.INVALID_GROUP_BY, `Invalid --group-by field: ${options.groupBy}`, { + groupBy: options.groupBy, + supported: [...GROUP_BY_FIELDS], + }) + } + groupBy = options.groupBy as GroupByField + } + + return { + fields, + completedOnly: !!options.completedOnly, + incompleteOnly: !!options.incompleteOnly, + assignee: options.assignee, + count: !!options.count, + groupBy, + } +} + +/** + * Whether the assignee filter must run client-side: project listings have no + * server-side assignee param, and "none" (unassigned) has no server equivalent. + */ +export function needsClientAssigneeFilter(query: TaskListQuery, hasProject: boolean): boolean { + if (!query.assignee) { + return false + } + return hasProject || query.assignee === 'none' +} + +function isQueryMode(query: TaskListQuery, clientAssignee: boolean): boolean { + return query.fields.length > 0 + || query.completedOnly + || query.incompleteOnly + || query.count + || !!query.groupBy + || clientAssignee +} + +/** + * Build the `opt_fields` value covering the requested output fields plus the + * fields the client-side filters/aggregation need. Returns undefined when no + * scan option is active so the legacy compact listing stays unchanged. + */ +export function buildOptFields(query: TaskListQuery, clientAssignee: boolean): string | undefined { + if (!isQueryMode(query, clientAssignee)) { + return undefined + } + const optFields = new Set(['name']) + for (const field of query.fields) { + optFields.add(field === 'assignee' ? 'assignee.name' : field) + } + if (query.completedOnly || query.incompleteOnly || query.groupBy === 'completed') { + optFields.add('completed') + } + if (clientAssignee || query.groupBy === 'assignee' || query.fields.includes('assignee')) { + optFields.add('assignee.name') + optFields.add('assignee.gid') + } + return [...optFields].join(',') +} + +export function applyCompletionFilter(tasks: any[], query: TaskListQuery): any[] { + if (query.completedOnly) { + return tasks.filter(task => task.completed === true) + } + if (query.incompleteOnly) { + return tasks.filter(task => !task.completed) + } + return tasks +} + +/** + * @param tasks tasks fetched with `assignee.gid` in opt_fields + * @param assignee `"none"` for unassigned tasks, otherwise a user GID + * ("me" must be resolved to a GID by the caller first) + */ +export function applyAssigneeFilter(tasks: any[], assignee: string): any[] { + if (assignee === 'none') { + return tasks.filter(task => !task.assignee) + } + return tasks.filter(task => task.assignee?.gid === assignee) +} + +/** + * Columns to print besides gid/name: the requested fields, or the fields the + * active filters implied. Empty means "keep the raw listing". + */ +export function effectiveColumns(query: TaskListQuery, clientAssignee: boolean): string[] { + if (query.fields.length > 0) { + return query.fields + } + const columns: string[] = [] + if (query.completedOnly || query.incompleteOnly) { + columns.push('completed') + } + if (clientAssignee) { + columns.push('assignee') + } + return columns +} + +/** + * Flatten tasks into uniform rows (gid, name + columns). Assignee objects are + * flattened to a display name; missing values become null so tabular formats + * keep their columns aligned. + */ +export function toTaskRows(tasks: any[], columns: string[]): Array> { + return tasks.map((task) => { + const row: Record = { gid: task.gid, name: task.name } + for (const column of columns) { + if (column === 'gid' || column === 'name') { + continue + } + row[column] = column === 'assignee' + ? (task.assignee?.name ?? task.assignee?.gid ?? null) + : (task[column] ?? null) + } + return row + }) +} + +export interface TaskListSummary { + total: number + groups?: Array> +} + +/** + * Precomputed aggregate for `--count` / `--group-by` (AXI principle 4): + * totals instead of rows, so a scan question costs one round-trip. + */ +export function summarizeTasks(tasks: any[], groupBy?: GroupByField): TaskListSummary { + if (!groupBy) { + return { total: tasks.length } + } + const counts = new Map() + for (const task of tasks) { + const key = groupBy === 'completed' + ? (task.completed ? 'completed' : 'incomplete') + : (task.assignee?.name ?? task.assignee?.gid ?? 'unassigned') + counts.set(key, (counts.get(key) ?? 0) + 1) + } + const groups = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([key, count]) => ({ [groupBy]: key, count })) + return { total: tasks.length, groups } +} diff --git a/src/types/index.ts b/src/types/index.ts index fc5ba5d..e971bbd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,7 +22,13 @@ export interface TaskListOptions { assignee?: string workspace?: string project?: string + /** Deprecated: excludes completed tasks (historical behavior). Use `incompleteOnly`. */ completed?: boolean + fields?: string + completedOnly?: boolean + incompleteOnly?: boolean + count?: boolean + groupBy?: string } export interface TaskUpdateOptions { diff --git a/test/commands/task-list-scan.test.ts b/test/commands/task-list-scan.test.ts new file mode 100644 index 0000000..2664d20 --- /dev/null +++ b/test/commands/task-list-scan.test.ts @@ -0,0 +1,142 @@ +import { afterAll, afterEach, describe, expect, mock, test } from 'bun:test' +import { Command } from 'commander' +import * as realClient from '../../src/lib/asana-client' +import * as realConfig from '../../src/lib/config' + +// Bun's `mock.restore()` does NOT revert `mock.module()` registrations — +// capture the real modules and re-install them when this file finishes +// (same pattern as task-list-format.test.ts). +const REAL_CLIENT = { ...realClient } +const REAL_CONFIG = { ...realConfig } + +/** + * Scan-style `task list` options (issue #88): `--fields`, completion filters, + * `--assignee none`, `--count`, `--group-by`. Verifies both the request + * parameters sent to the API (opt_fields, completed_since) and the shaped + * output, using a mocked Asana client. + */ +describe('task list scan options', () => { + afterEach(() => { + mock.restore() + }) + + afterAll(() => { + mock.module('../../src/lib/asana-client', () => REAL_CLIENT) + mock.module('../../src/lib/config', () => REAL_CONFIG) + }) + + const PROJECT_TASKS = [ + { gid: '1', name: 'A', completed: false, assignee: { gid: '10', name: 'Alice' }, due_on: '2026-07-01' }, + { gid: '2', name: 'B', completed: true, assignee: { gid: '10', name: 'Alice' }, due_on: null }, + { gid: '3', name: 'C', completed: false, assignee: null, due_on: null }, + ] + + async function runList(args: string[]): Promise<{ out: string, calls: any[] }> { + const calls: any[] = [] + mock.module('../../src/lib/asana-client', () => ({ + getAsanaClient: () => ({ + tasks: { + findByProject: async (projectGid: string, opts: any) => { + calls.push({ method: 'findByProject', projectGid, opts }) + return { data: PROJECT_TASKS } + }, + findAll: async (opts: any) => { + calls.push({ method: 'findAll', opts }) + return { data: PROJECT_TASKS } + }, + }, + users: { + me: async () => ({ gid: '10', name: 'Alice' }), + }, + }), + })) + mock.module('../../src/lib/config', () => ({ + loadConfig: () => ({ workspace: '123' }), + })) + + const { createTaskCommand } = await import('../../src/commands/task') + const program = new Command() + program.name('asana').option('-f, --format ', 'Output format', 'toon') + program.addCommand(createTaskCommand()) + + const logs: string[] = [] + const original = console.log + console.log = (...logArgs: any[]) => { + logs.push(logArgs.join(' ')) + } + try { + await program.parseAsync(['--format', 'json', 'task', 'list', ...args], { from: 'user' }) + } + finally { + console.log = original + } + return { out: logs.join('\n'), calls } + } + + test('--fields expands opt_fields and flattens rows', async () => { + const { out, calls } = await runList(['-p', '99', '--fields', 'completed,assignee,due_on']) + const optFields = calls[0].opts.opt_fields.split(',') + expect(optFields).toContain('completed') + expect(optFields).toContain('due_on') + expect(optFields).toContain('assignee.name') + + const { tasks } = JSON.parse(out) + expect(tasks).toEqual([ + { gid: '1', name: 'A', completed: false, assignee: 'Alice', due_on: '2026-07-01' }, + { gid: '2', name: 'B', completed: true, assignee: 'Alice', due_on: null }, + { gid: '3', name: 'C', completed: false, assignee: null, due_on: null }, + ]) + }) + + test('--incomplete-only filters server-side and client-side', async () => { + const { out, calls } = await runList(['-p', '99', '--incomplete-only']) + expect(calls[0].opts.completed_since).toBe('now') + const { tasks } = JSON.parse(out) + expect(tasks.map((t: any) => t.gid)).toEqual(['1', '3']) + expect(tasks.every((t: any) => t.completed === false)).toBe(true) + }) + + test('--completed-only keeps only completed tasks', async () => { + const { out } = await runList(['-p', '99', '--completed-only']) + const { tasks } = JSON.parse(out) + expect(tasks.map((t: any) => t.gid)).toEqual(['2']) + }) + + test('--assignee none keeps only unassigned tasks in a project', async () => { + const { out } = await runList(['-p', '99', '--assignee', 'none']) + const { tasks } = JSON.parse(out) + expect(tasks.map((t: any) => t.gid)).toEqual(['3']) + }) + + test('--assignee me resolves the current user for project listings', async () => { + const { out } = await runList(['-p', '99', '--assignee', 'me']) + const { tasks } = JSON.parse(out) + expect(tasks.map((t: any) => t.gid)).toEqual(['1', '2']) + }) + + test('--count returns only the total', async () => { + const { out } = await runList(['-p', '99', '--count']) + expect(JSON.parse(out)).toEqual({ summary: { total: 3 } }) + }) + + test('--incomplete-only --group-by assignee aggregates in one call', async () => { + const { out, calls } = await runList(['-p', '99', '--incomplete-only', '--group-by', 'assignee']) + expect(calls).toHaveLength(1) + expect(JSON.parse(out)).toEqual({ + summary: { + total: 2, + groups: [ + { assignee: 'Alice', count: 1 }, + { assignee: 'unassigned', count: 1 }, + ], + }, + }) + }) + + test('plain listing keeps the legacy request shape (no opt_fields)', async () => { + const { calls } = await runList(['-a', 'me']) + expect(calls[0].method).toBe('findAll') + expect(calls[0].opts.opt_fields).toBeUndefined() + expect(calls[0].opts.assignee).toBe('me') + }) +}) diff --git a/test/lib/task-list-query.test.ts b/test/lib/task-list-query.test.ts new file mode 100644 index 0000000..f7f5515 --- /dev/null +++ b/test/lib/task-list-query.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'bun:test' +import { + applyAssigneeFilter, + applyCompletionFilter, + buildOptFields, + effectiveColumns, + needsClientAssigneeFilter, + parseTaskListQuery, + summarizeTasks, + toTaskRows, +} from '../../src/lib/task-list-query' +import { ValidationError } from '../../src/lib/validators' + +const TASKS = [ + { gid: '1', name: 'A', completed: false, assignee: { gid: '10', name: 'Alice' }, due_on: '2026-07-01' }, + { gid: '2', name: 'B', completed: true, assignee: { gid: '10', name: 'Alice' }, due_on: null }, + { gid: '3', name: 'C', completed: false, assignee: null, due_on: null }, + { gid: '4', name: 'D', completed: false, assignee: { gid: '20', name: 'Bob' }, due_on: null }, +] + +describe('parseTaskListQuery', () => { + test('parses comma-separated fields, trimming whitespace', () => { + const query = parseTaskListQuery({ fields: ' completed, assignee ,due_on' }) + expect(query.fields).toEqual(['completed', 'assignee', 'due_on']) + }) + + test('rejects field names with unsafe characters', () => { + expect(() => parseTaskListQuery({ fields: 'name,foo&bar' })).toThrow(ValidationError) + }) + + test('rejects --fields with no usable field names', () => { + expect(() => parseTaskListQuery({ fields: ' , ' })).toThrow(ValidationError) + }) + + test('rejects --completed-only combined with --incomplete-only', () => { + expect(() => parseTaskListQuery({ completedOnly: true, incompleteOnly: true })).toThrow(ValidationError) + }) + + test('rejects --completed-only combined with deprecated --completed', () => { + expect(() => parseTaskListQuery({ completedOnly: true, completed: true })).toThrow(ValidationError) + }) + + test('rejects unknown --group-by field', () => { + expect(() => parseTaskListQuery({ groupBy: 'due_on' })).toThrow(ValidationError) + }) + + test('accepts group-by assignee and completed', () => { + expect(parseTaskListQuery({ groupBy: 'assignee' }).groupBy).toBe('assignee') + expect(parseTaskListQuery({ groupBy: 'completed' }).groupBy).toBe('completed') + }) +}) + +describe('needsClientAssigneeFilter', () => { + test('project listing always filters assignee client-side', () => { + const query = parseTaskListQuery({ assignee: 'me' }) + expect(needsClientAssigneeFilter(query, true)).toBe(true) + }) + + test('assignee "none" filters client-side even without project', () => { + const query = parseTaskListQuery({ assignee: 'none' }) + expect(needsClientAssigneeFilter(query, false)).toBe(true) + }) + + test('workspace listing with a concrete assignee stays server-side', () => { + const query = parseTaskListQuery({ assignee: 'me' }) + expect(needsClientAssigneeFilter(query, false)).toBe(false) + }) +}) + +describe('buildOptFields', () => { + test('returns undefined when no scan options are used (legacy output)', () => { + const query = parseTaskListQuery({ assignee: 'me' }) + expect(buildOptFields(query, false)).toBeUndefined() + }) + + test('maps requested fields to opt_fields, expanding assignee', () => { + const query = parseTaskListQuery({ fields: 'completed,assignee,due_on' }) + const optFields = buildOptFields(query, false)!.split(',') + expect(optFields).toContain('name') + expect(optFields).toContain('completed') + expect(optFields).toContain('due_on') + expect(optFields).toContain('assignee.name') + expect(optFields).toContain('assignee.gid') + }) + + test('adds completed for completion filters and group-by', () => { + expect(buildOptFields(parseTaskListQuery({ incompleteOnly: true }), false)).toContain('completed') + expect(buildOptFields(parseTaskListQuery({ completedOnly: true }), false)).toContain('completed') + expect(buildOptFields(parseTaskListQuery({ groupBy: 'completed' }), false)).toContain('completed') + }) + + test('adds assignee fields for client-side assignee filter and group-by', () => { + expect(buildOptFields(parseTaskListQuery({ assignee: 'none' }), true)).toContain('assignee.gid') + expect(buildOptFields(parseTaskListQuery({ groupBy: 'assignee' }), false)).toContain('assignee.name') + }) +}) + +describe('applyCompletionFilter', () => { + test('keeps only incomplete tasks with --incomplete-only', () => { + const query = parseTaskListQuery({ incompleteOnly: true }) + expect(applyCompletionFilter(TASKS, query).map(t => t.gid)).toEqual(['1', '3', '4']) + }) + + test('keeps only completed tasks with --completed-only', () => { + const query = parseTaskListQuery({ completedOnly: true }) + expect(applyCompletionFilter(TASKS, query).map(t => t.gid)).toEqual(['2']) + }) + + test('passes through when no completion filter is set', () => { + const query = parseTaskListQuery({}) + expect(applyCompletionFilter(TASKS, query)).toHaveLength(4) + }) +}) + +describe('applyAssigneeFilter', () => { + test('"none" keeps only unassigned tasks', () => { + expect(applyAssigneeFilter(TASKS, 'none').map(t => t.gid)).toEqual(['3']) + }) + + test('a user GID keeps only that assignee', () => { + expect(applyAssigneeFilter(TASKS, '10').map(t => t.gid)).toEqual(['1', '2']) + }) +}) + +describe('effectiveColumns / toTaskRows', () => { + test('uses requested fields as columns', () => { + const query = parseTaskListQuery({ fields: 'completed,due_on' }) + expect(effectiveColumns(query, false)).toEqual(['completed', 'due_on']) + }) + + test('derives columns from active filters when --fields is absent', () => { + const query = parseTaskListQuery({ incompleteOnly: true, assignee: 'none' }) + expect(effectiveColumns(query, true)).toEqual(['completed', 'assignee']) + }) + + test('flattens assignee to a name and fills missing values with null', () => { + const rows = toTaskRows(TASKS.slice(0, 3), ['completed', 'assignee', 'due_on']) + expect(rows[0]).toEqual({ gid: '1', name: 'A', completed: false, assignee: 'Alice', due_on: '2026-07-01' }) + expect(rows[2]).toEqual({ gid: '3', name: 'C', completed: false, assignee: null, due_on: null }) + }) +}) + +describe('summarizeTasks', () => { + test('returns only the total without group-by', () => { + expect(summarizeTasks(TASKS)).toEqual({ total: 4 }) + }) + + test('groups by assignee with unassigned bucket, sorted by count', () => { + expect(summarizeTasks(TASKS, 'assignee')).toEqual({ + total: 4, + groups: [ + { assignee: 'Alice', count: 2 }, + { assignee: 'Bob', count: 1 }, + { assignee: 'unassigned', count: 1 }, + ], + }) + }) + + test('groups by completion state', () => { + expect(summarizeTasks(TASKS, 'completed')).toEqual({ + total: 4, + groups: [ + { completed: 'incomplete', count: 3 }, + { completed: 'completed', count: 1 }, + ], + }) + }) +}) From 9b7ddf4fed7851d7b47fcc6e2c7da583f50079e9 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 6 Jul 2026 18:28:07 +0900 Subject: [PATCH 3/3] chore: apply AI code review suggestions - resolve dotted --fields paths (e.g. assignee.name) in toTaskRows via a nested getter instead of a flat key lookup - correct the module docblock: validation helpers print to stderr before throwing, so they are not pure --- src/lib/task-list-query.ts | 15 +++++++++++---- test/lib/task-list-query.test.ts | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/lib/task-list-query.ts b/src/lib/task-list-query.ts index 6a91978..8ea0b59 100644 --- a/src/lib/task-list-query.ts +++ b/src/lib/task-list-query.ts @@ -6,7 +6,9 @@ * `task get` per task (issue #88). These helpers let a single listing call * carry the fields needed to filter and aggregate client-side. * - * All functions here are pure; the command layer owns the API calls. + * No API calls happen here — the command layer owns those. Validation + * failures print a human-readable message to stderr before throwing, + * following the validators.ts convention. */ import type { TaskListOptions } from '../types' @@ -169,10 +171,15 @@ export function effectiveColumns(query: TaskListQuery, clientAssignee: boolean): return columns } +/** Resolve a dotted field path (e.g. "assignee.name") against a task object. */ +function getFieldValue(task: any, path: string): any { + return path.split('.').reduce((value, segment) => value?.[segment], task) +} + /** * Flatten tasks into uniform rows (gid, name + columns). Assignee objects are - * flattened to a display name; missing values become null so tabular formats - * keep their columns aligned. + * flattened to a display name, dotted paths resolve into nested objects, and + * missing values become null so tabular formats keep their columns aligned. */ export function toTaskRows(tasks: any[], columns: string[]): Array> { return tasks.map((task) => { @@ -183,7 +190,7 @@ export function toTaskRows(tasks: any[], columns: string[]): Array { expect(rows[0]).toEqual({ gid: '1', name: 'A', completed: false, assignee: 'Alice', due_on: '2026-07-01' }) expect(rows[2]).toEqual({ gid: '3', name: 'C', completed: false, assignee: null, due_on: null }) }) + + test('resolves dotted field paths against nested objects', () => { + const rows = toTaskRows(TASKS.slice(0, 3), ['assignee.name', 'assignee.gid']) + expect(rows[0]).toEqual({ 'gid': '1', 'name': 'A', 'assignee.name': 'Alice', 'assignee.gid': '10' }) + expect(rows[2]).toEqual({ 'gid': '3', 'name': 'C', 'assignee.name': null, 'assignee.gid': null }) + }) }) describe('summarizeTasks', () => {