From 378f5477e4aaec9ef463eefa88b3e24c10f66a60 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:56:00 +0900 Subject: [PATCH] =?UTF-8?q?list=20=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=81=AE=E3=83=AA=E3=83=95=E3=82=A1=E3=82=AF=E3=82=BF=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=B0:=20non-interactive=20=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=89=E5=88=86=E9=9B=A2=E3=80=81delete=20=E3=82=A2=E3=82=AF?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E8=BF=BD=E5=8A=A0=E3=80=81console.l?= =?UTF-8?q?og=20=E3=82=92=20info=20=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 +- src/__tests__/listNonInteractive.test.ts | 181 ++++++++++++ src/__tests__/listTasks.test.ts | 212 +++++++++++++- src/__tests__/taskDeleteActions.test.ts | 180 ++++++++++++ src/features/tasks/list/index.ts | 270 ++++++++---------- src/features/tasks/list/listNonInteractive.ts | 140 +++++++++ src/features/tasks/list/taskActions.ts | 10 +- src/features/tasks/list/taskDeleteActions.ts | 54 ++++ src/infra/task/index.ts | 1 + src/infra/task/runner.ts | 79 ++++- src/infra/task/types.ts | 9 + 11 files changed, 975 insertions(+), 165 deletions(-) create mode 100644 src/__tests__/listNonInteractive.test.ts create mode 100644 src/__tests__/taskDeleteActions.test.ts create mode 100644 src/features/tasks/list/listNonInteractive.ts create mode 100644 src/features/tasks/list/taskDeleteActions.ts diff --git a/package-lock.json b/package-lock.json index b4b3fdf..3696a8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.6.0-rc2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.6.0-rc2", + "version": "0.6.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.19", diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts new file mode 100644 index 0000000..b25ab07 --- /dev/null +++ b/src/__tests__/listNonInteractive.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for listNonInteractive — non-interactive list output and branch actions. + */ + +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { listTasks } from '../features/tasks/list/index.js'; + +describe('listTasks non-interactive text output', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-')); + execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should output pending tasks in text format', async () => { + // Given + const tasksDir = path.join(tmpDir, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When + await listTasks(tmpDir, undefined, { enabled: true }); + + // Then + const calls = logSpy.mock.calls.map((c) => c[0] as string); + expect(calls).toContainEqual(expect.stringContaining('[pending] my-task')); + expect(calls).toContainEqual(expect.stringContaining('Fix the login bug')); + logSpy.mockRestore(); + }); + + it('should output failed tasks in text format', async () => { + // Given + const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task'); + fs.mkdirSync(failedDir, { recursive: true }); + fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When + await listTasks(tmpDir, undefined, { enabled: true }); + + // Then + const calls = logSpy.mock.calls.map((c) => c[0] as string); + expect(calls).toContainEqual(expect.stringContaining('[failed] failed-task')); + expect(calls).toContainEqual(expect.stringContaining('This failed')); + logSpy.mockRestore(); + }); + + it('should output both pending and failed tasks in text format', async () => { + // Given + const tasksDir = path.join(tmpDir, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, 'pending-one.md'), 'Pending task'); + + const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-one'); + fs.mkdirSync(failedDir, { recursive: true }); + fs.writeFileSync(path.join(failedDir, 'failed-one.md'), 'Failed task'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When + await listTasks(tmpDir, undefined, { enabled: true }); + + // Then + const calls = logSpy.mock.calls.map((c) => c[0] as string); + expect(calls).toContainEqual(expect.stringContaining('[pending] pending-one')); + expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one')); + logSpy.mockRestore(); + }); + + it('should show info message when no tasks exist', async () => { + // Given: no tasks, no branches + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When + await listTasks(tmpDir, undefined, { enabled: true }); + + // Then + const calls = logSpy.mock.calls.map((c) => c[0] as string); + expect(calls.some((c) => c.includes('No tasks to list'))).toBe(true); + logSpy.mockRestore(); + }); +}); + +describe('listTasks non-interactive action errors', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-err-')); + execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); + // Create a pending task so the "no tasks" early return is not triggered + const tasksDir = path.join(tmpDir, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, 'dummy.md'), 'dummy'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should exit with code 1 when --action specified without --branch', async () => { + // Given + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When / Then + await expect( + listTasks(tmpDir, undefined, { enabled: true, action: 'diff' }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('should exit with code 1 for invalid action', async () => { + // Given + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When / Then + await expect( + listTasks(tmpDir, undefined, { enabled: true, action: 'invalid', branch: 'some-branch' }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('should exit with code 1 when branch not found', async () => { + // Given + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When / Then + await expect( + listTasks(tmpDir, undefined, { enabled: true, action: 'diff', branch: 'takt/nonexistent' }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('should exit with code 1 for delete without --yes', async () => { + // Given: create a branch so it's found + execFileSync('git', ['checkout', '-b', 'takt/20250115-test-branch'], { cwd: tmpDir, stdio: 'pipe' }); + execFileSync('git', ['checkout', 'main'], { cwd: tmpDir, stdio: 'pipe' }); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When / Then + await expect( + listTasks(tmpDir, undefined, { + enabled: true, + action: 'delete', + branch: 'takt/20250115-test-branch', + }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/listTasks.test.ts b/src/__tests__/listTasks.test.ts index ecc1729..ba3b182 100644 --- a/src/__tests__/listTasks.test.ts +++ b/src/__tests__/listTasks.test.ts @@ -2,14 +2,21 @@ * Tests for list-tasks command */ -import { describe, it, expect, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { parseTaktBranches, extractTaskSlug, buildListItems, type BranchInfo, } from '../infra/task/branchList.js'; +import { TaskRunner } from '../infra/task/runner.js'; +import type { TaskListItem } from '../infra/task/types.js'; import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js'; +import { listTasks } from '../features/tasks/list/index.js'; describe('parseTaktBranches', () => { it('should parse takt/ branches from git branch output', () => { @@ -178,3 +185,206 @@ describe('isBranchMerged', () => { expect(result).toBe(false); }); }); + +describe('TaskRunner.listFailedTasks', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty array for empty failed directory', () => { + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + expect(result).toEqual([]); + }); + + it('should parse failed task directories correctly', () => { + const failedDir = path.join(tmpDir, '.takt', 'failed'); + const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my-task'); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync(path.join(taskDir, 'my-task.md'), 'Fix the login bug\nMore details here'); + + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'failed', + name: 'my-task', + createdAt: '2025-01-15T12:34:56', + filePath: taskDir, + content: 'Fix the login bug', + }); + }); + + it('should skip malformed directory names', () => { + const failedDir = path.join(tmpDir, '.takt', 'failed'); + // No underscore → malformed, should be skipped + fs.mkdirSync(path.join(failedDir, 'malformed-name'), { recursive: true }); + // Valid one + const validDir = path.join(failedDir, '2025-01-15T12-34-56_valid-task'); + fs.mkdirSync(validDir, { recursive: true }); + fs.writeFileSync(path.join(validDir, 'valid-task.md'), 'Content'); + + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('valid-task'); + }); + + it('should extract task content from task file in directory', () => { + const failedDir = path.join(tmpDir, '.takt', 'failed'); + const taskDir = path.join(failedDir, '2025-02-01T00-00-00_content-test'); + fs.mkdirSync(taskDir, { recursive: true }); + // report.md and log.json should be skipped; the actual task file should be read + fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content'); + fs.writeFileSync(path.join(taskDir, 'log.json'), '{}'); + fs.writeFileSync(path.join(taskDir, 'content-test.yaml'), 'task: Do something important'); + + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + + expect(result).toHaveLength(1); + expect(result[0]!.content).toBe('task: Do something important'); + }); + + it('should return empty content when no task file exists', () => { + const failedDir = path.join(tmpDir, '.takt', 'failed'); + const taskDir = path.join(failedDir, '2025-02-01T00-00-00_no-task-file'); + fs.mkdirSync(taskDir, { recursive: true }); + // Only report.md and log.json, no actual task file + fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content'); + fs.writeFileSync(path.join(taskDir, 'log.json'), '{}'); + + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + + expect(result).toHaveLength(1); + expect(result[0]!.content).toBe(''); + }); + + it('should handle task name with underscores', () => { + const failedDir = path.join(tmpDir, '.takt', 'failed'); + const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my_task_name'); + fs.mkdirSync(taskDir, { recursive: true }); + + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('my_task_name'); + }); + + it('should skip non-directory entries', () => { + const failedDir = path.join(tmpDir, '.takt', 'failed'); + fs.mkdirSync(failedDir, { recursive: true }); + // Create a file (not a directory) in the failed dir + fs.writeFileSync(path.join(failedDir, '2025-01-15T12-34-56_file-task'), 'content'); + + const runner = new TaskRunner(tmpDir); + const result = runner.listFailedTasks(); + + expect(result).toHaveLength(0); + }); +}); + +describe('TaskRunner.listPendingTaskItems', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty array when no pending tasks', () => { + const runner = new TaskRunner(tmpDir); + const result = runner.listPendingTaskItems(); + expect(result).toEqual([]); + }); + + it('should convert TaskInfo to TaskListItem with kind=pending', () => { + const tasksDir = path.join(tmpDir, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug\nMore details here'); + + const runner = new TaskRunner(tmpDir); + const result = runner.listPendingTaskItems(); + + expect(result).toHaveLength(1); + expect(result[0]!.kind).toBe('pending'); + expect(result[0]!.name).toBe('my-task'); + expect(result[0]!.content).toBe('Fix the login bug'); + }); + + it('should truncate content to first line (max 80 chars)', () => { + const tasksDir = path.join(tmpDir, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + const longLine = 'A'.repeat(120) + '\nSecond line'; + fs.writeFileSync(path.join(tasksDir, 'long-task.md'), longLine); + + const runner = new TaskRunner(tmpDir); + const result = runner.listPendingTaskItems(); + + expect(result).toHaveLength(1); + expect(result[0]!.content).toBe('A'.repeat(80)); + }); +}); + +describe('listTasks non-interactive JSON output', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-json-')); + // Initialize as a git repo so detectDefaultBranch works + execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should output JSON as object with branches, pendingTasks, and failedTasks keys', async () => { + // Given: a pending task and a failed task + const tasksDir = path.join(tmpDir, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Do something'); + + const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task'); + fs.mkdirSync(failedDir, { recursive: true }); + fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // When: listTasks is called in non-interactive JSON mode + await listTasks(tmpDir, undefined, { + enabled: true, + format: 'json', + }); + + // Then: output is an object with branches, pendingTasks, failedTasks + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(logSpy.mock.calls[0]![0] as string); + expect(output).toHaveProperty('branches'); + expect(output).toHaveProperty('pendingTasks'); + expect(output).toHaveProperty('failedTasks'); + expect(Array.isArray(output.branches)).toBe(true); + expect(Array.isArray(output.pendingTasks)).toBe(true); + expect(Array.isArray(output.failedTasks)).toBe(true); + expect(output.pendingTasks).toHaveLength(1); + expect(output.pendingTasks[0].name).toBe('my-task'); + expect(output.failedTasks).toHaveLength(1); + expect(output.failedTasks[0].name).toBe('failed-task'); + + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts new file mode 100644 index 0000000..47cecdb --- /dev/null +++ b/src/__tests__/taskDeleteActions.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for taskDeleteActions — pending/failed task deletion + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../shared/prompt/index.js', () => ({ + confirm: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + }), +})); + +import { confirm } from '../shared/prompt/index.js'; +import { success, error as logError } from '../shared/ui/index.js'; +import { deletePendingTask, deleteFailedTask } from '../features/tasks/list/taskDeleteActions.js'; +import type { TaskListItem } from '../infra/task/types.js'; + +const mockConfirm = vi.mocked(confirm); +const mockSuccess = vi.mocked(success); +const mockLogError = vi.mocked(logError); + +let tmpDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('deletePendingTask', () => { + it('should delete pending task file when confirmed', async () => { + // Given + const filePath = path.join(tmpDir, 'my-task.md'); + fs.writeFileSync(filePath, 'task content'); + const task: TaskListItem = { + kind: 'pending', + name: 'my-task', + createdAt: '2025-01-15', + filePath, + content: 'task content', + }; + mockConfirm.mockResolvedValue(true); + + // When + const result = await deletePendingTask(task); + + // Then + expect(result).toBe(true); + expect(fs.existsSync(filePath)).toBe(false); + expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: my-task'); + }); + + it('should not delete when user declines confirmation', async () => { + // Given + const filePath = path.join(tmpDir, 'my-task.md'); + fs.writeFileSync(filePath, 'task content'); + const task: TaskListItem = { + kind: 'pending', + name: 'my-task', + createdAt: '2025-01-15', + filePath, + content: 'task content', + }; + mockConfirm.mockResolvedValue(false); + + // When + const result = await deletePendingTask(task); + + // Then + expect(result).toBe(false); + expect(fs.existsSync(filePath)).toBe(true); + expect(mockSuccess).not.toHaveBeenCalled(); + }); + + it('should return false and show error when file does not exist', async () => { + // Given + const filePath = path.join(tmpDir, 'non-existent.md'); + const task: TaskListItem = { + kind: 'pending', + name: 'non-existent', + createdAt: '2025-01-15', + filePath, + content: '', + }; + mockConfirm.mockResolvedValue(true); + + // When + const result = await deletePendingTask(task); + + // Then + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalled(); + expect(mockSuccess).not.toHaveBeenCalled(); + }); +}); + +describe('deleteFailedTask', () => { + it('should delete failed task directory when confirmed', async () => { + // Given + const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task'); + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, 'my-task.md'), 'content'); + const task: TaskListItem = { + kind: 'failed', + name: 'my-task', + createdAt: '2025-01-15T12:34:56', + filePath: dirPath, + content: 'content', + }; + mockConfirm.mockResolvedValue(true); + + // When + const result = await deleteFailedTask(task); + + // Then + expect(result).toBe(true); + expect(fs.existsSync(dirPath)).toBe(false); + expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: my-task'); + }); + + it('should not delete when user declines confirmation', async () => { + // Given + const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task'); + fs.mkdirSync(dirPath, { recursive: true }); + const task: TaskListItem = { + kind: 'failed', + name: 'my-task', + createdAt: '2025-01-15T12:34:56', + filePath: dirPath, + content: '', + }; + mockConfirm.mockResolvedValue(false); + + // When + const result = await deleteFailedTask(task); + + // Then + expect(result).toBe(false); + expect(fs.existsSync(dirPath)).toBe(true); + expect(mockSuccess).not.toHaveBeenCalled(); + }); + + it('should return false and show error when directory does not exist', async () => { + // Given + const dirPath = path.join(tmpDir, 'non-existent-dir'); + const task: TaskListItem = { + kind: 'failed', + name: 'non-existent', + createdAt: '2025-01-15T12:34:56', + filePath: dirPath, + content: '', + }; + mockConfirm.mockResolvedValue(true); + + // When + const result = await deleteFailedTask(task); + + // Then + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalled(); + expect(mockSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 5195654..140787a 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -1,21 +1,23 @@ /** * List tasks command — main entry point. * - * Interactive UI for reviewing branch-based task results. + * Interactive UI for reviewing branch-based task results, + * pending tasks (.takt/tasks/), and failed tasks (.takt/failed/). * Individual actions (merge, delete, instruct, diff) are in taskActions.ts. + * Task delete actions are in taskDeleteActions.ts. + * Non-interactive mode is in listNonInteractive.ts. */ -import { execFileSync } from 'node:child_process'; import { - detectDefaultBranch, listTaktBranches, buildListItems, + detectDefaultBranch, + TaskRunner, } from '../../../infra/task/index.js'; +import type { TaskListItem } from '../../../infra/task/index.js'; import { selectOption, confirm } from '../../../shared/prompt/index.js'; -import { info } from '../../../shared/ui/index.js'; -import { createLogger } from '../../../shared/utils/index.js'; +import { info, header, blankLine } from '../../../shared/ui/index.js'; import type { TaskExecutionOptions } from '../execute/types.js'; -import type { BranchListItem } from '../../../infra/task/index.js'; import { type ListAction, showFullDiff, @@ -25,6 +27,10 @@ import { deleteBranch, instructBranch, } from './taskActions.js'; +import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; +import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; + +export type { ListNonInteractiveOptions } from './listNonInteractive.js'; export { type ListAction, @@ -36,100 +42,25 @@ export { instructBranch, } from './taskActions.js'; -const log = createLogger('list-tasks'); +/** Task action type for the task action selection menu */ +type TaskAction = 'delete'; -export interface ListNonInteractiveOptions { - enabled: boolean; - action?: string; - branch?: string; - format?: string; - yes?: boolean; -} - -function isValidAction(action: string): action is ListAction { - return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete'; -} - -function printNonInteractiveList(items: BranchListItem[], format?: string): void { - const outputFormat = format ?? 'text'; - if (outputFormat === 'json') { - console.log(JSON.stringify(items, null, 2)); - return; +/** + * Show task details and prompt for an action. + * Returns the selected action, or null if cancelled. + */ +async function showTaskAndPromptAction(task: TaskListItem): Promise { + header(`[${task.kind}] ${task.name}`); + info(` Created: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); } + blankLine(); - for (const item of items) { - const worktreeLabel = item.info.worktreePath ? ' (worktree)' : ''; - const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : ''; - console.log(`${item.info.branch}${worktreeLabel} (${item.filesChanged} files)${instruction}`); - } -} - -function showDiffStat(projectDir: string, defaultBranch: string, branch: string): void { - try { - const stat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ); - console.log(stat); - } catch { - info('Could not generate diff stat'); - } -} - -async function listTasksNonInteractive( - cwd: string, - _options: TaskExecutionOptions | undefined, - nonInteractive: ListNonInteractiveOptions, -): Promise { - const defaultBranch = detectDefaultBranch(cwd); - const branches = listTaktBranches(cwd); - - if (branches.length === 0) { - info('No tasks to list.'); - return; - } - - const items = buildListItems(cwd, branches, defaultBranch); - - if (!nonInteractive.action) { - printNonInteractiveList(items, nonInteractive.format); - return; - } - - if (!nonInteractive.branch) { - info('Missing --branch for non-interactive action.'); - process.exit(1); - } - - if (!isValidAction(nonInteractive.action)) { - info('Invalid --action. Use one of: diff, try, merge, delete.'); - process.exit(1); - } - - const item = items.find((entry) => entry.info.branch === nonInteractive.branch); - if (!item) { - info(`Branch not found: ${nonInteractive.branch}`); - process.exit(1); - } - - switch (nonInteractive.action) { - case 'diff': - showDiffStat(cwd, defaultBranch, item.info.branch); - return; - case 'try': - tryMergeBranch(cwd, item); - return; - case 'merge': - mergeBranch(cwd, item); - return; - case 'delete': - if (!nonInteractive.yes) { - info('Delete requires --yes in non-interactive mode.'); - process.exit(1); - } - deleteBranch(cwd, item); - return; - } + return await selectOption( + `Action for ${task.name}:`, + [{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' }], + ); } /** @@ -140,39 +71,52 @@ export async function listTasks( options?: TaskExecutionOptions, nonInteractive?: ListNonInteractiveOptions, ): Promise { - log.info('Starting list-tasks'); - if (nonInteractive?.enabled) { - await listTasksNonInteractive(cwd, options, nonInteractive); + await listTasksNonInteractive(cwd, nonInteractive); return; } const defaultBranch = detectDefaultBranch(cwd); - let branches = listTaktBranches(cwd); - - if (branches.length === 0) { - info('No tasks to list.'); - return; - } + const runner = new TaskRunner(cwd); // Interactive loop - while (branches.length > 0) { + while (true) { + const branches = listTaktBranches(cwd); const items = buildListItems(cwd, branches, defaultBranch); + const pendingTasks = runner.listPendingTaskItems(); + const failedTasks = runner.listFailedTasks(); - const menuOptions = items.map((item, idx) => { - const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; - const description = item.originalInstruction - ? `${filesSummary} | ${item.originalInstruction}` - : filesSummary; - return { - label: item.info.branch, - value: String(idx), - description, - }; - }); + if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { + info('No tasks to list.'); + return; + } + + const menuOptions = [ + ...items.map((item, idx) => { + const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; + const description = item.originalInstruction + ? `${filesSummary} | ${item.originalInstruction}` + : filesSummary; + return { + label: item.info.branch, + value: `branch:${idx}`, + description, + }; + }), + ...pendingTasks.map((task, idx) => ({ + label: `[pending] ${task.name}`, + value: `pending:${idx}`, + description: task.content, + })), + ...failedTasks.map((task, idx) => ({ + label: `[failed] ${task.name}`, + value: `failed:${idx}`, + description: task.content, + })), + ]; const selected = await selectOption( - 'List Tasks (Branches)', + 'List Tasks', menuOptions, ); @@ -180,47 +124,63 @@ export async function listTasks( return; } - const selectedIdx = parseInt(selected, 10); - const item = items[selectedIdx]; - if (!item) continue; + const colonIdx = selected.indexOf(':'); + if (colonIdx === -1) continue; + const type = selected.slice(0, colonIdx); + const idx = parseInt(selected.slice(colonIdx + 1), 10); + if (Number.isNaN(idx)) continue; - // Action loop: re-show menu after viewing diff - let action: ListAction | null; - do { - action = await showDiffAndPromptAction(cwd, defaultBranch, item); + if (type === 'branch') { + const item = items[idx]; + if (!item) continue; - if (action === 'diff') { - showFullDiff(cwd, defaultBranch, item.info.branch); - } - } while (action === 'diff'); + // Action loop: re-show menu after viewing diff + let action: ListAction | null; + do { + action = await showDiffAndPromptAction(cwd, defaultBranch, item); - if (action === null) continue; - - switch (action) { - case 'instruct': - await instructBranch(cwd, item, options); - break; - case 'try': - tryMergeBranch(cwd, item); - break; - case 'merge': - mergeBranch(cwd, item); - break; - case 'delete': { - const confirmed = await confirm( - `Delete ${item.info.branch}? This will discard all changes.`, - false, - ); - if (confirmed) { - deleteBranch(cwd, item); + if (action === 'diff') { + showFullDiff(cwd, defaultBranch, item.info.branch); } - break; + } while (action === 'diff'); + + if (action === null) continue; + + switch (action) { + case 'instruct': + await instructBranch(cwd, item, options); + break; + case 'try': + tryMergeBranch(cwd, item); + break; + case 'merge': + mergeBranch(cwd, item); + break; + case 'delete': { + const confirmed = await confirm( + `Delete ${item.info.branch}? This will discard all changes.`, + false, + ); + if (confirmed) { + deleteBranch(cwd, item); + } + break; + } + } + } else if (type === 'pending') { + const task = pendingTasks[idx]; + if (!task) continue; + const taskAction = await showTaskAndPromptAction(task); + if (taskAction === 'delete') { + await deletePendingTask(task); + } + } else if (type === 'failed') { + const task = failedTasks[idx]; + if (!task) continue; + const taskAction = await showTaskAndPromptAction(task); + if (taskAction === 'delete') { + await deleteFailedTask(task); } } - - // Refresh branch list after action - branches = listTaktBranches(cwd); } - - info('All tasks listed.'); } diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts new file mode 100644 index 0000000..9c133f4 --- /dev/null +++ b/src/features/tasks/list/listNonInteractive.ts @@ -0,0 +1,140 @@ +/** + * Non-interactive list mode. + * + * Handles --non-interactive output (text/JSON) and + * non-interactive branch actions (--action, --branch). + */ + +import { execFileSync } from 'node:child_process'; +import type { TaskListItem, BranchListItem } from '../../../infra/task/index.js'; +import { + detectDefaultBranch, + listTaktBranches, + buildListItems, + TaskRunner, +} from '../../../infra/task/index.js'; +import { info } from '../../../shared/ui/index.js'; +import { + type ListAction, + tryMergeBranch, + mergeBranch, + deleteBranch, +} from './taskActions.js'; + +export interface ListNonInteractiveOptions { + enabled: boolean; + action?: string; + branch?: string; + format?: string; + yes?: boolean; +} + +function isValidAction(action: string): action is ListAction { + return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete'; +} + +function printNonInteractiveList( + items: BranchListItem[], + pendingTasks: TaskListItem[], + failedTasks: TaskListItem[], + format?: string, +): void { + const outputFormat = format ?? 'text'; + if (outputFormat === 'json') { + // stdout に直接出力(JSON パース用途のため UI ヘルパーを経由しない) + console.log(JSON.stringify({ + branches: items, + pendingTasks, + failedTasks, + }, null, 2)); + return; + } + + for (const item of items) { + const worktreeLabel = item.info.worktreePath ? ' (worktree)' : ''; + const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : ''; + info(`${item.info.branch}${worktreeLabel} (${item.filesChanged} files)${instruction}`); + } + + for (const task of pendingTasks) { + info(`[pending] ${task.name} - ${task.content}`); + } + + for (const task of failedTasks) { + info(`[failed] ${task.name} - ${task.content}`); + } +} + +function showDiffStat(projectDir: string, defaultBranch: string, branch: string): void { + try { + const stat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ); + info(stat); + } catch { + info('Could not generate diff stat'); + } +} + +/** + * Run list-tasks in non-interactive mode. + */ +export async function listTasksNonInteractive( + cwd: string, + nonInteractive: ListNonInteractiveOptions, +): Promise { + const defaultBranch = detectDefaultBranch(cwd); + const branches = listTaktBranches(cwd); + const runner = new TaskRunner(cwd); + const pendingTasks = runner.listPendingTaskItems(); + const failedTasks = runner.listFailedTasks(); + + const items = buildListItems(cwd, branches, defaultBranch); + + if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { + info('No tasks to list.'); + return; + } + + if (!nonInteractive.action) { + printNonInteractiveList(items, pendingTasks, failedTasks, nonInteractive.format); + return; + } + + // Branch-targeted action (--branch) + if (!nonInteractive.branch) { + info('Missing --branch for non-interactive action.'); + process.exit(1); + } + + if (!isValidAction(nonInteractive.action)) { + info('Invalid --action. Use one of: diff, try, merge, delete.'); + process.exit(1); + } + + const item = items.find((entry) => entry.info.branch === nonInteractive.branch); + if (!item) { + info(`Branch not found: ${nonInteractive.branch}`); + process.exit(1); + } + + switch (nonInteractive.action) { + case 'diff': + showDiffStat(cwd, defaultBranch, item.info.branch); + return; + case 'try': + tryMergeBranch(cwd, item); + return; + case 'merge': + mergeBranch(cwd, item); + return; + case 'delete': + if (!nonInteractive.yes) { + info('Delete requires --yes in non-interactive mode.'); + process.exit(1); + } + deleteBranch(cwd, item); + return; + } +} diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index bdc1002..59a32c1 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -8,17 +8,16 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { rmSync, existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; + import chalk from 'chalk'; import { createTempCloneForBranch, removeClone, removeCloneMeta, cleanupOrphanedClone, -} from '../../../infra/task/index.js'; -import { detectDefaultBranch, - type BranchListItem, autoCommitAndPush, + type BranchListItem, } from '../../../infra/task/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js'; @@ -86,7 +85,7 @@ export async function showDiffAndPromptAction( ): Promise { header(item.info.branch); if (item.originalInstruction) { - console.log(chalk.dim(` ${item.originalInstruction}`)); + info(chalk.dim(` ${item.originalInstruction}`)); } blankLine(); @@ -95,7 +94,7 @@ export async function showDiffAndPromptAction( 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], { cwd, encoding: 'utf-8', stdio: 'pipe' }, ); - console.log(stat); + info(stat); } catch { warn('Could not generate diff stat'); } @@ -362,3 +361,4 @@ export async function instructBranch( removeCloneMeta(projectDir, branch); } } + diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts new file mode 100644 index 0000000..128dfe1 --- /dev/null +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -0,0 +1,54 @@ +/** + * Delete actions for pending and failed tasks. + * + * Provides interactive deletion (with confirm prompt) + * for pending task files and failed task directories. + */ + +import { rmSync, unlinkSync } from 'node:fs'; +import type { TaskListItem } from '../../../infra/task/index.js'; +import { confirm } from '../../../shared/prompt/index.js'; +import { success, error as logError } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; + +const log = createLogger('list-tasks'); + +/** + * Delete a pending task file. + * Prompts user for confirmation first. + */ +export async function deletePendingTask(task: TaskListItem): Promise { + const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); + if (!confirmed) return false; + try { + unlinkSync(task.filePath); + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to delete pending task "${task.name}": ${msg}`); + log.error('Failed to delete pending task', { name: task.name, filePath: task.filePath, error: msg }); + return false; + } + success(`Deleted pending task: ${task.name}`); + log.info('Deleted pending task', { name: task.name, filePath: task.filePath }); + return true; +} + +/** + * Delete a failed task directory. + * Prompts user for confirmation first. + */ +export async function deleteFailedTask(task: TaskListItem): Promise { + const confirmed = await confirm(`Delete failed task "${task.name}" and its logs?`, false); + if (!confirmed) return false; + try { + rmSync(task.filePath, { recursive: true }); + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to delete failed task "${task.name}": ${msg}`); + log.error('Failed to delete failed task', { name: task.name, filePath: task.filePath, error: msg }); + return false; + } + success(`Deleted failed task: ${task.name}`); + log.info('Deleted failed task', { name: task.name, filePath: task.filePath }); + return true; +} diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index 25596de..e6ea017 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -11,6 +11,7 @@ export type { BranchInfo, BranchListItem, SummarizeOptions, + TaskListItem, } from './types.js'; // Classes diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 8102854..a443b8b 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -16,9 +16,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseTaskFiles, parseTaskFile, type ParsedTask } from './parser.js'; -import type { TaskInfo, TaskResult } from './types.js'; +import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; +import { createLogger } from '../../shared/utils/index.js'; -export type { TaskInfo, TaskResult }; +export type { TaskInfo, TaskResult, TaskListItem }; + +const log = createLogger('task-runner'); /** * タスク実行管理クラス @@ -129,6 +132,78 @@ export class TaskRunner { return this.moveTask(result, this.failedDir); } + /** + * pendingタスクを TaskListItem 形式で取得 + */ + listPendingTaskItems(): TaskListItem[] { + return this.listTasks().map((task) => ({ + kind: 'pending' as const, + name: task.name, + createdAt: task.createdAt, + filePath: task.filePath, + content: task.content.trim().split('\n')[0]?.slice(0, 80) ?? '', + })); + } + + /** + * failedタスクの一覧を取得 + * .takt/failed/ 内のサブディレクトリを走査し、TaskListItem を返す + */ + listFailedTasks(): TaskListItem[] { + this.ensureDirs(); + + const entries = fs.readdirSync(this.failedDir); + + return entries + .filter((entry) => { + const entryPath = path.join(this.failedDir, entry); + return fs.statSync(entryPath).isDirectory() && entry.includes('_'); + }) + .map((entry) => { + const entryPath = path.join(this.failedDir, entry); + const underscoreIdx = entry.indexOf('_'); + const timestampRaw = entry.slice(0, underscoreIdx); + const name = entry.slice(underscoreIdx + 1); + const createdAt = timestampRaw.replace( + /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})$/, + '$1:$2:$3', + ); + const content = this.readFailedTaskContent(entryPath); + return { kind: 'failed' as const, name, createdAt, filePath: entryPath, content }; + }) + .filter((item) => item.name !== ''); + } + + /** + * failedタスクディレクトリ内のタスクファイルから先頭1行を読み取る + */ + private readFailedTaskContent(dirPath: string): string { + const taskExtensions = ['.md', '.yaml', '.yml']; + let files: string[]; + try { + files = fs.readdirSync(dirPath); + } catch (err) { + log.error('Failed to read failed task directory', { dirPath, error: String(err) }); + return ''; + } + + for (const file of files) { + const ext = path.extname(file); + if (file === 'report.md' || file === 'log.json') continue; + if (!taskExtensions.includes(ext)) continue; + + try { + const raw = fs.readFileSync(path.join(dirPath, file), 'utf-8'); + return raw.trim().split('\n')[0]?.slice(0, 80) ?? ''; + } catch (err) { + log.error('Failed to read failed task file', { file, dirPath, error: String(err) }); + continue; + } + } + + return ''; + } + /** * タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する */ diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 1cb038e..f142a02 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -66,3 +66,12 @@ export interface SummarizeOptions { /** Use LLM for summarization (default: true). If false, uses romanization. */ useLLM?: boolean; } + +/** pending/failedタスクのリストアイテム */ +export interface TaskListItem { + kind: 'pending' | 'failed'; + name: string; + createdAt: string; + filePath: string; + content: string; +}