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",
|
"name": "takt",
|
||||||
"version": "0.6.0-rc2",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "takt",
|
"name": "takt",
|
||||||
"version": "0.6.0-rc2",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.19",
|
"@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
|
* 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 {
|
import {
|
||||||
parseTaktBranches,
|
parseTaktBranches,
|
||||||
extractTaskSlug,
|
extractTaskSlug,
|
||||||
buildListItems,
|
buildListItems,
|
||||||
type BranchInfo,
|
type BranchInfo,
|
||||||
} from '../infra/task/branchList.js';
|
} 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 { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js';
|
||||||
|
import { listTasks } from '../features/tasks/list/index.js';
|
||||||
|
|
||||||
describe('parseTaktBranches', () => {
|
describe('parseTaktBranches', () => {
|
||||||
it('should parse takt/ branches from git branch output', () => {
|
it('should parse takt/ branches from git branch output', () => {
|
||||||
@ -178,3 +185,206 @@ describe('isBranchMerged', () => {
|
|||||||
expect(result).toBe(false);
|
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.
|
* 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.
|
* 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 {
|
import {
|
||||||
detectDefaultBranch,
|
|
||||||
listTaktBranches,
|
listTaktBranches,
|
||||||
buildListItems,
|
buildListItems,
|
||||||
|
detectDefaultBranch,
|
||||||
|
TaskRunner,
|
||||||
} from '../../../infra/task/index.js';
|
} from '../../../infra/task/index.js';
|
||||||
|
import type { TaskListItem } from '../../../infra/task/index.js';
|
||||||
import { selectOption, confirm } from '../../../shared/prompt/index.js';
|
import { selectOption, confirm } from '../../../shared/prompt/index.js';
|
||||||
import { info } from '../../../shared/ui/index.js';
|
import { info, header, blankLine } from '../../../shared/ui/index.js';
|
||||||
import { createLogger } from '../../../shared/utils/index.js';
|
|
||||||
import type { TaskExecutionOptions } from '../execute/types.js';
|
import type { TaskExecutionOptions } from '../execute/types.js';
|
||||||
import type { BranchListItem } from '../../../infra/task/index.js';
|
|
||||||
import {
|
import {
|
||||||
type ListAction,
|
type ListAction,
|
||||||
showFullDiff,
|
showFullDiff,
|
||||||
@ -25,6 +27,10 @@ import {
|
|||||||
deleteBranch,
|
deleteBranch,
|
||||||
instructBranch,
|
instructBranch,
|
||||||
} from './taskActions.js';
|
} from './taskActions.js';
|
||||||
|
import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js';
|
||||||
|
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
|
|
||||||
|
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type ListAction,
|
type ListAction,
|
||||||
@ -36,100 +42,25 @@ export {
|
|||||||
instructBranch,
|
instructBranch,
|
||||||
} from './taskActions.js';
|
} 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;
|
* Show task details and prompt for an action.
|
||||||
action?: string;
|
* Returns the selected action, or null if cancelled.
|
||||||
branch?: string;
|
*/
|
||||||
format?: string;
|
async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction | null> {
|
||||||
yes?: boolean;
|
header(`[${task.kind}] ${task.name}`);
|
||||||
}
|
info(` Created: ${task.createdAt}`);
|
||||||
|
if (task.content) {
|
||||||
function isValidAction(action: string): action is ListAction {
|
info(` ${task.content}`);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
blankLine();
|
||||||
|
|
||||||
for (const item of items) {
|
return await selectOption<TaskAction>(
|
||||||
const worktreeLabel = item.info.worktreePath ? ' (worktree)' : '';
|
`Action for ${task.name}:`,
|
||||||
const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : '';
|
[{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' }],
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -140,39 +71,52 @@ export async function listTasks(
|
|||||||
options?: TaskExecutionOptions,
|
options?: TaskExecutionOptions,
|
||||||
nonInteractive?: ListNonInteractiveOptions,
|
nonInteractive?: ListNonInteractiveOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
log.info('Starting list-tasks');
|
|
||||||
|
|
||||||
if (nonInteractive?.enabled) {
|
if (nonInteractive?.enabled) {
|
||||||
await listTasksNonInteractive(cwd, options, nonInteractive);
|
await listTasksNonInteractive(cwd, nonInteractive);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultBranch = detectDefaultBranch(cwd);
|
const defaultBranch = detectDefaultBranch(cwd);
|
||||||
let branches = listTaktBranches(cwd);
|
const runner = new TaskRunner(cwd);
|
||||||
|
|
||||||
if (branches.length === 0) {
|
|
||||||
info('No tasks to list.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interactive loop
|
// Interactive loop
|
||||||
while (branches.length > 0) {
|
while (true) {
|
||||||
|
const branches = listTaktBranches(cwd);
|
||||||
const items = buildListItems(cwd, branches, defaultBranch);
|
const items = buildListItems(cwd, branches, defaultBranch);
|
||||||
|
const pendingTasks = runner.listPendingTaskItems();
|
||||||
|
const failedTasks = runner.listFailedTasks();
|
||||||
|
|
||||||
const menuOptions = items.map((item, idx) => {
|
if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) {
|
||||||
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
info('No tasks to list.');
|
||||||
const description = item.originalInstruction
|
return;
|
||||||
? `${filesSummary} | ${item.originalInstruction}`
|
}
|
||||||
: filesSummary;
|
|
||||||
return {
|
const menuOptions = [
|
||||||
label: item.info.branch,
|
...items.map((item, idx) => {
|
||||||
value: String(idx),
|
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
||||||
description,
|
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>(
|
const selected = await selectOption<string>(
|
||||||
'List Tasks (Branches)',
|
'List Tasks',
|
||||||
menuOptions,
|
menuOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -180,47 +124,63 @@ export async function listTasks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIdx = parseInt(selected, 10);
|
const colonIdx = selected.indexOf(':');
|
||||||
const item = items[selectedIdx];
|
if (colonIdx === -1) continue;
|
||||||
if (!item) 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
|
if (type === 'branch') {
|
||||||
let action: ListAction | null;
|
const item = items[idx];
|
||||||
do {
|
if (!item) continue;
|
||||||
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
|
|
||||||
|
|
||||||
if (action === 'diff') {
|
// Action loop: re-show menu after viewing diff
|
||||||
showFullDiff(cwd, defaultBranch, item.info.branch);
|
let action: ListAction | null;
|
||||||
}
|
do {
|
||||||
} while (action === 'diff');
|
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
|
||||||
|
|
||||||
if (action === null) continue;
|
if (action === 'diff') {
|
||||||
|
showFullDiff(cwd, defaultBranch, item.info.branch);
|
||||||
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;
|
} 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 { execFileSync, spawnSync } from 'node:child_process';
|
||||||
import { rmSync, existsSync, unlinkSync } from 'node:fs';
|
import { rmSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import {
|
import {
|
||||||
createTempCloneForBranch,
|
createTempCloneForBranch,
|
||||||
removeClone,
|
removeClone,
|
||||||
removeCloneMeta,
|
removeCloneMeta,
|
||||||
cleanupOrphanedClone,
|
cleanupOrphanedClone,
|
||||||
} from '../../../infra/task/index.js';
|
|
||||||
import {
|
|
||||||
detectDefaultBranch,
|
detectDefaultBranch,
|
||||||
type BranchListItem,
|
|
||||||
autoCommitAndPush,
|
autoCommitAndPush,
|
||||||
|
type BranchListItem,
|
||||||
} from '../../../infra/task/index.js';
|
} from '../../../infra/task/index.js';
|
||||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||||
import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/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> {
|
): Promise<ListAction | null> {
|
||||||
header(item.info.branch);
|
header(item.info.branch);
|
||||||
if (item.originalInstruction) {
|
if (item.originalInstruction) {
|
||||||
console.log(chalk.dim(` ${item.originalInstruction}`));
|
info(chalk.dim(` ${item.originalInstruction}`));
|
||||||
}
|
}
|
||||||
blankLine();
|
blankLine();
|
||||||
|
|
||||||
@ -95,7 +94,7 @@ export async function showDiffAndPromptAction(
|
|||||||
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
|
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
|
||||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||||
);
|
);
|
||||||
console.log(stat);
|
info(stat);
|
||||||
} catch {
|
} catch {
|
||||||
warn('Could not generate diff stat');
|
warn('Could not generate diff stat');
|
||||||
}
|
}
|
||||||
@ -362,3 +361,4 @@ export async function instructBranch(
|
|||||||
removeCloneMeta(projectDir, branch);
|
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,
|
BranchInfo,
|
||||||
BranchListItem,
|
BranchListItem,
|
||||||
SummarizeOptions,
|
SummarizeOptions,
|
||||||
|
TaskListItem,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Classes
|
// Classes
|
||||||
|
|||||||
@ -16,9 +16,12 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parseTaskFiles, parseTaskFile, type ParsedTask } from './parser.js';
|
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);
|
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. */
|
/** Use LLM for summarization (default: true). If false, uses romanization. */
|
||||||
useLLM?: boolean;
|
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