list コマンドのリファクタリング: non-interactive モード分離、delete アクション追加、console.log を info に統一
This commit is contained in:
parent
a2af2f23e3
commit
378f5477e4
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
181
src/__tests__/listNonInteractive.test.ts
Normal file
181
src/__tests__/listNonInteractive.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
180
src/__tests__/taskDeleteActions.test.ts
Normal file
180
src/__tests__/taskDeleteActions.test.ts
Normal file
@ -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<Record<string, unknown>>()),
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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<TaskAction | null> {
|
||||
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<void> {
|
||||
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<TaskAction>(
|
||||
`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<void> {
|
||||
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<string>(
|
||||
'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.');
|
||||
}
|
||||
|
||||
140
src/features/tasks/list/listNonInteractive.ts
Normal file
140
src/features/tasks/list/listNonInteractive.ts
Normal file
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<ListAction | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
src/features/tasks/list/taskDeleteActions.ts
Normal file
54
src/features/tasks/list/taskDeleteActions.ts
Normal file
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@ -11,6 +11,7 @@ export type {
|
||||
BranchInfo,
|
||||
BranchListItem,
|
||||
SummarizeOptions,
|
||||
TaskListItem,
|
||||
} from './types.js';
|
||||
|
||||
// Classes
|
||||
|
||||
@ -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 '';
|
||||
}
|
||||
|
||||
/**
|
||||
* タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user