takt: add-ai-consultation-actions
This commit is contained in:
parent
653b55a034
commit
c044aea33f
@ -38,7 +38,7 @@ sequenceDiagram
|
||||
|
||||
User->>Interactive: /go コマンド
|
||||
Interactive->>Interactive: buildTaskFromHistory()
|
||||
Interactive-->>CLI: { confirmed: true, task: string }
|
||||
Interactive-->>CLI: { action: InteractiveModeAction, task: string }
|
||||
|
||||
CLI->>Orchestration: selectAndExecuteTask(cwd, task)
|
||||
|
||||
|
||||
@ -355,7 +355,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され
|
||||
|
||||
**データ出力**:
|
||||
- `InteractiveModeResult`:
|
||||
- `confirmed: boolean`
|
||||
- `action: InteractiveModeAction` (`'execute' | 'save_task' | 'create_issue' | 'cancel'`)
|
||||
- `task: string` (会話履歴全体を結合した文字列)
|
||||
|
||||
---
|
||||
|
||||
@ -70,6 +70,7 @@ vi.mock('../infra/github/issue.js', () => ({
|
||||
}
|
||||
return numbers;
|
||||
}),
|
||||
createIssue: vi.fn(),
|
||||
}));
|
||||
|
||||
import { interactiveMode } from '../features/interactive/index.js';
|
||||
@ -77,10 +78,11 @@ import { promptInput, confirm } from '../shared/prompt/index.js';
|
||||
import { summarizeTaskName } from '../infra/task/summarize.js';
|
||||
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
|
||||
import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js';
|
||||
import { resolveIssueTask } from '../infra/github/issue.js';
|
||||
import { resolveIssueTask, createIssue } from '../infra/github/issue.js';
|
||||
import { addTask } from '../features/tasks/index.js';
|
||||
|
||||
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
||||
const mockCreateIssue = vi.mocked(createIssue);
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockPromptInput = vi.mocked(promptInput);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
@ -97,7 +99,7 @@ function setupFullFlowMocks(overrides?: {
|
||||
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' });
|
||||
mockInteractiveMode.mockResolvedValue({ confirmed: true, task });
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task });
|
||||
mockSummarizeTaskName.mockResolvedValue(slug);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
}
|
||||
@ -122,7 +124,7 @@ describe('addTask', () => {
|
||||
it('should cancel when interactive mode is not confirmed', async () => {
|
||||
// Given: user cancels interactive mode
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' });
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
@ -341,4 +343,51 @@ describe('addTask', () => {
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('issue: 99');
|
||||
});
|
||||
|
||||
describe('create_issue action', () => {
|
||||
it('should call createIssue when create_issue action is selected', async () => {
|
||||
// Given: interactive mode returns create_issue action
|
||||
const task = 'Create a new feature\nWith detailed description';
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: createIssue is called via createIssueFromTask
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: 'Create a new feature',
|
||||
body: task,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create task file when create_issue action is selected', async () => {
|
||||
// Given: interactive mode returns create_issue action
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: no task file created
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
|
||||
expect(files.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not prompt for worktree settings when create_issue action is selected', async () => {
|
||||
// Given: interactive mode returns create_issue action
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: confirm (worktree prompt) is never called
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
137
src/__tests__/createIssue.test.ts
Normal file
137
src/__tests__/createIssue.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Tests for createIssue function
|
||||
*
|
||||
* createIssue uses `gh issue create` via execFileSync, which is an
|
||||
* integration concern. Tests focus on argument construction and error handling
|
||||
* by mocking child_process.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createIssue, checkGhCli } from '../infra/github/issue.js';
|
||||
|
||||
const mockExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createIssue', () => {
|
||||
it('should return success with URL when gh issue create succeeds', () => {
|
||||
// Given: gh auth and issue creation both succeed
|
||||
mockExecFileSync
|
||||
.mockReturnValueOnce(Buffer.from('')) // gh auth status
|
||||
.mockReturnValueOnce('https://github.com/owner/repo/issues/42\n' as unknown as Buffer);
|
||||
|
||||
// When
|
||||
const result = createIssue({ title: 'Test issue', body: 'Test body' });
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.url).toBe('https://github.com/owner/repo/issues/42');
|
||||
});
|
||||
|
||||
it('should pass title and body as arguments', () => {
|
||||
// Given
|
||||
mockExecFileSync
|
||||
.mockReturnValueOnce(Buffer.from('')) // gh auth status
|
||||
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
|
||||
|
||||
// When
|
||||
createIssue({ title: 'My Title', body: 'My Body' });
|
||||
|
||||
// Then: verify the second call (issue create) has correct args
|
||||
const issueCreateCall = mockExecFileSync.mock.calls[1];
|
||||
expect(issueCreateCall?.[0]).toBe('gh');
|
||||
expect(issueCreateCall?.[1]).toEqual([
|
||||
'issue', 'create', '--title', 'My Title', '--body', 'My Body',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include labels when provided', () => {
|
||||
// Given
|
||||
mockExecFileSync
|
||||
.mockReturnValueOnce(Buffer.from('')) // gh auth status
|
||||
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
|
||||
|
||||
// When
|
||||
createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'priority:high'] });
|
||||
|
||||
// Then
|
||||
const issueCreateCall = mockExecFileSync.mock.calls[1];
|
||||
expect(issueCreateCall?.[1]).toEqual([
|
||||
'issue', 'create', '--title', 'Bug', '--body', 'Fix it',
|
||||
'--label', 'bug,priority:high',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not include --label when labels is empty', () => {
|
||||
// Given
|
||||
mockExecFileSync
|
||||
.mockReturnValueOnce(Buffer.from('')) // gh auth status
|
||||
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
|
||||
|
||||
// When
|
||||
createIssue({ title: 'Title', body: 'Body', labels: [] });
|
||||
|
||||
// Then
|
||||
const issueCreateCall = mockExecFileSync.mock.calls[1];
|
||||
expect(issueCreateCall?.[1]).not.toContain('--label');
|
||||
});
|
||||
|
||||
it('should return error when gh CLI is not authenticated', () => {
|
||||
// Given: auth fails, version succeeds
|
||||
mockExecFileSync
|
||||
.mockImplementationOnce(() => { throw new Error('not authenticated'); })
|
||||
.mockReturnValueOnce(Buffer.from('gh version 2.0.0'));
|
||||
|
||||
// When
|
||||
const result = createIssue({ title: 'Test', body: 'Body' });
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not authenticated');
|
||||
});
|
||||
|
||||
it('should return error when gh CLI is not installed', () => {
|
||||
// Given: both auth and version fail
|
||||
mockExecFileSync
|
||||
.mockImplementationOnce(() => { throw new Error('command not found'); })
|
||||
.mockImplementationOnce(() => { throw new Error('command not found'); });
|
||||
|
||||
// When
|
||||
const result = createIssue({ title: 'Test', body: 'Body' });
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not installed');
|
||||
});
|
||||
|
||||
it('should return error when gh issue create fails', () => {
|
||||
// Given: auth succeeds but issue creation fails
|
||||
mockExecFileSync
|
||||
.mockReturnValueOnce(Buffer.from('')) // gh auth status
|
||||
.mockImplementationOnce(() => { throw new Error('repo not found'); });
|
||||
|
||||
// When
|
||||
const result = createIssue({ title: 'Test', body: 'Body' });
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('repo not found');
|
||||
});
|
||||
});
|
||||
131
src/__tests__/createIssueFromTask.test.ts
Normal file
131
src/__tests__/createIssueFromTask.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tests for createIssueFromTask function
|
||||
*
|
||||
* Verifies title truncation (100-char boundary), success/failure UI output,
|
||||
* and multi-line task handling (first line → title, full text → body).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../infra/github/issue.js', () => ({
|
||||
createIssue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createIssue } from '../infra/github/issue.js';
|
||||
import { success, error } from '../shared/ui/index.js';
|
||||
import { createIssueFromTask } from '../features/tasks/index.js';
|
||||
|
||||
const mockCreateIssue = vi.mocked(createIssue);
|
||||
const mockSuccess = vi.mocked(success);
|
||||
const mockError = vi.mocked(error);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createIssueFromTask', () => {
|
||||
describe('title truncation boundary', () => {
|
||||
it('should use title as-is when exactly 99 characters', () => {
|
||||
// Given: 99-character first line
|
||||
const title99 = 'a'.repeat(99);
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
createIssueFromTask(title99);
|
||||
|
||||
// Then: title passed without truncation
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: title99,
|
||||
body: title99,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use title as-is when exactly 100 characters', () => {
|
||||
// Given: 100-character first line
|
||||
const title100 = 'a'.repeat(100);
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
createIssueFromTask(title100);
|
||||
|
||||
// Then: title passed without truncation
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: title100,
|
||||
body: title100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should truncate title to 97 chars + ellipsis when 101 characters', () => {
|
||||
// Given: 101-character first line
|
||||
const title101 = 'a'.repeat(101);
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
createIssueFromTask(title101);
|
||||
|
||||
// Then: title truncated to 97 chars + "..."
|
||||
const expectedTitle = `${'a'.repeat(97)}...`;
|
||||
expect(expectedTitle).toHaveLength(100);
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: expectedTitle,
|
||||
body: title101,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display success message with URL when issue creation succeeds', () => {
|
||||
// Given
|
||||
const url = 'https://github.com/owner/repo/issues/42';
|
||||
mockCreateIssue.mockReturnValue({ success: true, url });
|
||||
|
||||
// When
|
||||
createIssueFromTask('Test task');
|
||||
|
||||
// Then
|
||||
expect(mockSuccess).toHaveBeenCalledWith(`Issue created: ${url}`);
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display error message when issue creation fails', () => {
|
||||
// Given
|
||||
const errorMsg = 'repo not found';
|
||||
mockCreateIssue.mockReturnValue({ success: false, error: errorMsg });
|
||||
|
||||
// When
|
||||
createIssueFromTask('Test task');
|
||||
|
||||
// Then
|
||||
expect(mockError).toHaveBeenCalledWith(`Failed to create issue: ${errorMsg}`);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use first line as title and full text as body for multi-line task', () => {
|
||||
// Given: multi-line task
|
||||
const task = 'First line title\nSecond line details\nThird line more info';
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
createIssueFromTask(task);
|
||||
|
||||
// Then: first line → title, full text → body
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: 'First line title',
|
||||
body: task,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -96,7 +96,8 @@ describe('label integrity', () => {
|
||||
expect(ui).toHaveProperty('summarizeFailed');
|
||||
expect(ui).toHaveProperty('continuePrompt');
|
||||
expect(ui).toHaveProperty('proposed');
|
||||
expect(ui).toHaveProperty('confirm');
|
||||
expect(ui).toHaveProperty('actionPrompt');
|
||||
expect(ui).toHaveProperty('actions');
|
||||
expect(ui).toHaveProperty('cancelled');
|
||||
});
|
||||
|
||||
|
||||
@ -123,11 +123,12 @@ function setupMockProvider(responses: string[]): void {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectOption.mockResolvedValue('yes');
|
||||
// selectPostSummaryAction uses selectOption with action values
|
||||
mockSelectOption.mockResolvedValue('execute');
|
||||
});
|
||||
|
||||
describe('interactiveMode', () => {
|
||||
it('should return confirmed=false when user types /cancel', async () => {
|
||||
it('should return action=cancel when user types /cancel', async () => {
|
||||
// Given
|
||||
setupInputSequence(['/cancel']);
|
||||
setupMockProvider([]);
|
||||
@ -136,11 +137,11 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.action).toBe('cancel');
|
||||
expect(result.task).toBe('');
|
||||
});
|
||||
|
||||
it('should return confirmed=false on EOF (Ctrl+D)', async () => {
|
||||
it('should return action=cancel on EOF (Ctrl+D)', async () => {
|
||||
// Given
|
||||
setupInputSequence([null]);
|
||||
setupMockProvider([]);
|
||||
@ -149,7 +150,7 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should call provider with allowed tools for codebase exploration', async () => {
|
||||
@ -172,7 +173,7 @@ describe('interactiveMode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return confirmed=true with task on /go after conversation', async () => {
|
||||
it('should return action=execute with task on /go after conversation', async () => {
|
||||
// Given
|
||||
setupInputSequence(['add auth feature', '/go']);
|
||||
setupMockProvider(['What kind of authentication?', 'Implement auth feature with chosen method.']);
|
||||
@ -181,7 +182,7 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('Implement auth feature with chosen method.');
|
||||
});
|
||||
|
||||
@ -193,8 +194,8 @@ describe('interactiveMode', () => {
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: should not confirm (fell through to /cancel)
|
||||
expect(result.confirmed).toBe(false);
|
||||
// Then: should cancel (fell through to /cancel)
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should skip empty input', async () => {
|
||||
@ -206,7 +207,7 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@ -220,7 +221,7 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: task should be a summary and prompt should include full history
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('Summarized task.');
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
const summaryPrompt = mockProvider.call.mock.calls[2]?.[1] as string;
|
||||
@ -259,7 +260,7 @@ describe('interactiveMode', () => {
|
||||
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a');
|
||||
|
||||
// /go should work because initialInput already started conversation
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('Clarify task for "a".');
|
||||
});
|
||||
|
||||
@ -278,12 +279,12 @@ describe('interactiveMode', () => {
|
||||
expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page');
|
||||
|
||||
// Task still contains all history for downstream use
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('Fix login page with clarified scope.');
|
||||
});
|
||||
|
||||
describe('/play command', () => {
|
||||
it('should return confirmed=true with task on /play command', async () => {
|
||||
it('should return action=execute with task on /play command', async () => {
|
||||
// Given
|
||||
setupInputSequence(['/play implement login feature']);
|
||||
setupMockProvider([]);
|
||||
@ -292,7 +293,7 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('implement login feature');
|
||||
});
|
||||
|
||||
@ -304,8 +305,8 @@ describe('interactiveMode', () => {
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: should not confirm (fell through to /cancel)
|
||||
expect(result.confirmed).toBe(false);
|
||||
// Then: should cancel (fell through to /cancel)
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should handle /play with leading/trailing spaces', async () => {
|
||||
@ -317,7 +318,7 @@ describe('interactiveMode', () => {
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('test task');
|
||||
});
|
||||
|
||||
@ -332,8 +333,64 @@ describe('interactiveMode', () => {
|
||||
// Then: provider should NOT have been called (no summary needed)
|
||||
const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call).not.toHaveBeenCalled();
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('quick task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('action selection after /go', () => {
|
||||
it('should return action=create_issue when user selects create issue', async () => {
|
||||
// Given
|
||||
setupInputSequence(['describe task', '/go']);
|
||||
setupMockProvider(['response', 'Summarized task.']);
|
||||
mockSelectOption.mockResolvedValue('create_issue');
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.action).toBe('create_issue');
|
||||
expect(result.task).toBe('Summarized task.');
|
||||
});
|
||||
|
||||
it('should return action=save_task when user selects save task', async () => {
|
||||
// Given
|
||||
setupInputSequence(['describe task', '/go']);
|
||||
setupMockProvider(['response', 'Summarized task.']);
|
||||
mockSelectOption.mockResolvedValue('save_task');
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.action).toBe('save_task');
|
||||
expect(result.task).toBe('Summarized task.');
|
||||
});
|
||||
|
||||
it('should continue editing when user selects continue', async () => {
|
||||
// Given: user selects 'continue' first, then cancels
|
||||
setupInputSequence(['describe task', '/go', '/cancel']);
|
||||
setupMockProvider(['response', 'Summarized task.']);
|
||||
mockSelectOption.mockResolvedValueOnce('continue');
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: should fall through to /cancel
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should continue editing when user presses ESC (null)', async () => {
|
||||
// Given: selectOption returns null (ESC), then user cancels
|
||||
setupInputSequence(['describe task', '/go', '/cancel']);
|
||||
setupMockProvider(['response', 'Summarized task.']);
|
||||
mockSelectOption.mockResolvedValueOnce(null);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: should fall through to /cancel
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
186
src/__tests__/saveTaskFile.test.ts
Normal file
186
src/__tests__/saveTaskFile.test.ts
Normal file
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Tests for saveTaskFile and saveTaskFromInteractive
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
vi.mock('../infra/task/summarize.js', () => ({
|
||||
summarizeTaskName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { summarizeTaskName } from '../infra/task/summarize.js';
|
||||
import { success, info } from '../shared/ui/index.js';
|
||||
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
|
||||
|
||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||
const mockSuccess = vi.mocked(success);
|
||||
const mockInfo = vi.mocked(info);
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
|
||||
mockSummarizeTaskName.mockResolvedValue('test-task');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('saveTaskFile', () => {
|
||||
it('should create task file with correct YAML content', async () => {
|
||||
// Given
|
||||
const taskContent = 'Implement feature X\nDetails here';
|
||||
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, taskContent);
|
||||
|
||||
// Then
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('Implement feature X');
|
||||
expect(content).toContain('Details here');
|
||||
});
|
||||
|
||||
it('should create .takt/tasks directory if it does not exist', async () => {
|
||||
// Given
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
expect(fs.existsSync(tasksDir)).toBe(false);
|
||||
|
||||
// When
|
||||
await saveTaskFile(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(fs.existsSync(tasksDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include piece in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('piece: review');
|
||||
});
|
||||
|
||||
it('should include issue number in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { issue: 42 });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('issue: 42');
|
||||
});
|
||||
|
||||
it('should include worktree in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { worktree: true });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('worktree: true');
|
||||
});
|
||||
|
||||
it('should include branch in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { branch: 'feat/my-branch' });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('branch: feat/my-branch');
|
||||
});
|
||||
|
||||
it('should not include optional fields when not specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Simple task');
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).not.toContain('piece:');
|
||||
expect(content).not.toContain('issue:');
|
||||
expect(content).not.toContain('worktree:');
|
||||
expect(content).not.toContain('branch:');
|
||||
});
|
||||
|
||||
it('should use first line for filename generation', async () => {
|
||||
// When
|
||||
await saveTaskFile(testDir, 'First line\nSecond line');
|
||||
|
||||
// Then
|
||||
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line', { cwd: testDir });
|
||||
});
|
||||
|
||||
it('should handle duplicate filenames with counter', async () => {
|
||||
// Given: first file already exists
|
||||
await saveTaskFile(testDir, 'Task 1');
|
||||
|
||||
// When: second file with same slug
|
||||
const filePath = await saveTaskFile(testDir, 'Task 2');
|
||||
|
||||
// Then
|
||||
expect(path.basename(filePath)).toBe('test-task-1.yaml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTaskFromInteractive', () => {
|
||||
it('should save task and display success message', async () => {
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
|
||||
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:'));
|
||||
});
|
||||
|
||||
it('should display piece info when specified', async () => {
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content', 'review');
|
||||
|
||||
// Then
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
|
||||
});
|
||||
|
||||
it('should include piece in saved YAML', async () => {
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content', 'custom');
|
||||
|
||||
// Then
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(1);
|
||||
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
|
||||
expect(content).toContain('piece: custom');
|
||||
});
|
||||
|
||||
it('should not display piece info when not specified', async () => {
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
const pieceInfoCalls = mockInfo.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('Piece:')
|
||||
);
|
||||
expect(pieceInfoCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -8,7 +8,7 @@
|
||||
import { info, error } from '../../shared/ui/index.js';
|
||||
import { getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { resolveIssueTask } from '../../infra/github/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
|
||||
import { executePipeline } from '../../features/pipeline/index.js';
|
||||
import { interactiveMode } from '../../features/interactive/index.js';
|
||||
import { getPieceDescription } from '../../infra/config/index.js';
|
||||
@ -99,14 +99,25 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
const pieceContext = getPieceDescription(pieceId, resolvedCwd);
|
||||
const result = await interactiveMode(resolvedCwd, task, pieceContext);
|
||||
|
||||
if (!result.confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'execute':
|
||||
selectOptions.interactiveUserInput = true;
|
||||
selectOptions.piece = pieceId;
|
||||
selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task };
|
||||
selectOptions.interactiveMetadata = { confirmed: true, task: result.task };
|
||||
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
|
||||
break;
|
||||
|
||||
case 'create_issue':
|
||||
createIssueFromTask(result.task);
|
||||
break;
|
||||
|
||||
case 'save_task':
|
||||
await saveTaskFromInteractive(resolvedCwd, result.task, pieceId);
|
||||
break;
|
||||
|
||||
case 'cancel':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
|
||||
@ -2,4 +2,9 @@
|
||||
* Interactive mode commands.
|
||||
*/
|
||||
|
||||
export { interactiveMode, type PieceContext, type InteractiveModeResult } from './interactive.js';
|
||||
export {
|
||||
interactiveMode,
|
||||
type PieceContext,
|
||||
type InteractiveModeResult,
|
||||
type InteractiveModeAction,
|
||||
} from './interactive.js';
|
||||
|
||||
@ -38,7 +38,13 @@ interface InteractiveUIText {
|
||||
summarizeFailed: string;
|
||||
continuePrompt: string;
|
||||
proposed: string;
|
||||
confirm: string;
|
||||
actionPrompt: string;
|
||||
actions: {
|
||||
execute: string;
|
||||
createIssue: string;
|
||||
saveTask: string;
|
||||
continue: string;
|
||||
};
|
||||
cancelled: string;
|
||||
playNoTask: string;
|
||||
}
|
||||
@ -149,15 +155,23 @@ function buildSummaryPrompt(
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmTask(task: string, message: string, confirmLabel: string, yesLabel: string, noLabel: string): Promise<boolean> {
|
||||
type PostSummaryAction = InteractiveModeAction | 'continue';
|
||||
|
||||
async function selectPostSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
ui: InteractiveUIText,
|
||||
): Promise<PostSummaryAction | null> {
|
||||
blankLine();
|
||||
info(message);
|
||||
info(proposedLabel);
|
||||
console.log(task);
|
||||
const decision = await selectOption(confirmLabel, [
|
||||
{ label: yesLabel, value: 'yes' },
|
||||
{ label: noLabel, value: 'no' },
|
||||
|
||||
return selectOption<PostSummaryAction>(ui.actionPrompt, [
|
||||
{ label: ui.actions.execute, value: 'execute' },
|
||||
{ label: ui.actions.createIssue, value: 'create_issue' },
|
||||
{ label: ui.actions.saveTask, value: 'save_task' },
|
||||
{ label: ui.actions.continue, value: 'continue' },
|
||||
]);
|
||||
return decision === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -221,10 +235,12 @@ async function callAI(
|
||||
return { content: response.content, sessionId: response.sessionId, success };
|
||||
}
|
||||
|
||||
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
|
||||
|
||||
export interface InteractiveModeResult {
|
||||
/** Whether the user confirmed with /go */
|
||||
confirmed: boolean;
|
||||
/** The assembled task text (only meaningful when confirmed=true) */
|
||||
/** The action selected by the user */
|
||||
action: InteractiveModeAction;
|
||||
/** The assembled task text (only meaningful when action is not 'cancel') */
|
||||
task: string;
|
||||
}
|
||||
|
||||
@ -338,7 +354,7 @@ export async function interactiveMode(
|
||||
if (!result.success) {
|
||||
error(result.content);
|
||||
blankLine();
|
||||
return { confirmed: false, task: '' };
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
history.push({ role: 'assistant', content: result.content });
|
||||
blankLine();
|
||||
@ -354,7 +370,7 @@ export async function interactiveMode(
|
||||
if (input === null) {
|
||||
blankLine();
|
||||
info('Cancelled');
|
||||
return { confirmed: false, task: '' };
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
@ -372,7 +388,7 @@ export async function interactiveMode(
|
||||
continue;
|
||||
}
|
||||
log.info('Play command', { task });
|
||||
return { confirmed: true, task };
|
||||
return { action: 'execute', task };
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/go')) {
|
||||
@ -400,27 +416,21 @@ export async function interactiveMode(
|
||||
if (!summaryResult.success) {
|
||||
error(summaryResult.content);
|
||||
blankLine();
|
||||
return { confirmed: false, task: '' };
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
const task = summaryResult.content.trim();
|
||||
const confirmed = await confirmTask(
|
||||
task,
|
||||
prompts.ui.proposed,
|
||||
prompts.ui.confirm,
|
||||
lang === 'ja' ? 'はい' : 'Yes',
|
||||
lang === 'ja' ? 'いいえ' : 'No',
|
||||
);
|
||||
if (!confirmed) {
|
||||
const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui);
|
||||
if (selectedAction === 'continue' || selectedAction === null) {
|
||||
info(prompts.ui.continuePrompt);
|
||||
continue;
|
||||
}
|
||||
log.info('Interactive mode confirmed', { messageCount: history.length });
|
||||
return { confirmed: true, task };
|
||||
log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length });
|
||||
return { action: selectedAction, task };
|
||||
}
|
||||
|
||||
if (trimmed === '/cancel') {
|
||||
info(prompts.ui.cancelled);
|
||||
return { confirmed: false, task: '' };
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
|
||||
// Regular input — send to AI
|
||||
@ -436,7 +446,7 @@ export async function interactiveMode(
|
||||
error(result.content);
|
||||
blankLine();
|
||||
history.pop();
|
||||
return { confirmed: false, task: '' };
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
history.push({ role: 'assistant', content: result.content });
|
||||
blankLine();
|
||||
|
||||
@ -9,12 +9,12 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { promptInput, confirm } from '../../../shared/prompt/index.js';
|
||||
import { success, info } from '../../../shared/ui/index.js';
|
||||
import { success, info, error } from '../../../shared/ui/index.js';
|
||||
import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js';
|
||||
import { getPieceDescription } from '../../../infra/config/index.js';
|
||||
import { determinePiece } from '../execute/selectAndExecute.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../../infra/github/index.js';
|
||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
|
||||
import { interactiveMode } from '../../interactive/index.js';
|
||||
|
||||
const log = createLogger('add-task');
|
||||
@ -34,6 +34,74 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a task file to .takt/tasks/ with YAML format.
|
||||
*
|
||||
* Common logic extracted from addTask(). Used by both addTask()
|
||||
* and saveTaskFromInteractive().
|
||||
*/
|
||||
export async function saveTaskFile(
|
||||
cwd: string,
|
||||
taskContent: string,
|
||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string },
|
||||
): Promise<string> {
|
||||
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
|
||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
||||
|
||||
const taskData: TaskFileData = {
|
||||
task: taskContent,
|
||||
...(options?.worktree !== undefined && { worktree: options.worktree }),
|
||||
...(options?.branch && { branch: options.branch }),
|
||||
...(options?.piece && { piece: options.piece }),
|
||||
...(options?.issue !== undefined && { issue: options.issue }),
|
||||
};
|
||||
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
const yamlContent = stringifyYaml(taskData);
|
||||
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
||||
|
||||
log.info('Task created', { filePath, taskData });
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue from a task description.
|
||||
*
|
||||
* Extracts the first line as the issue title (truncated to 100 chars),
|
||||
* uses the full task as the body, and displays success/error messages.
|
||||
*/
|
||||
export function createIssueFromTask(task: string): void {
|
||||
info('Creating GitHub Issue...');
|
||||
const firstLine = task.split('\n')[0] || task;
|
||||
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
|
||||
const issueResult = createIssue({ title, body: task });
|
||||
if (issueResult.success) {
|
||||
success(`Issue created: ${issueResult.url}`);
|
||||
} else {
|
||||
error(`Failed to create issue: ${issueResult.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a task from interactive mode result.
|
||||
* Does not prompt for worktree/branch settings.
|
||||
*/
|
||||
export async function saveTaskFromInteractive(
|
||||
cwd: string,
|
||||
task: string,
|
||||
piece?: string,
|
||||
): Promise<void> {
|
||||
const filePath = await saveTaskFile(cwd, task, { piece });
|
||||
const filename = path.basename(filePath);
|
||||
success(`Task created: ${filename}`);
|
||||
info(` Path: ${filePath}`);
|
||||
if (piece) info(` Piece: ${piece}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* add command handler
|
||||
*
|
||||
@ -82,7 +150,13 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
|
||||
// Interactive mode: AI conversation to refine task
|
||||
const result = await interactiveMode(cwd, undefined, pieceContext);
|
||||
if (!result.confirmed) {
|
||||
|
||||
if (result.action === 'create_issue') {
|
||||
createIssueFromTask(result.task);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action !== 'execute' && result.action !== 'save_task') {
|
||||
info('Cancelled.');
|
||||
return;
|
||||
}
|
||||
@ -91,11 +165,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
taskContent = result.task;
|
||||
}
|
||||
|
||||
// 3. 要約からファイル名生成
|
||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
||||
|
||||
// 4. ワークツリー/ブランチ設定
|
||||
// 3. ワークツリー/ブランチ設定
|
||||
let worktree: boolean | string | undefined;
|
||||
let branch: string | undefined;
|
||||
|
||||
@ -110,27 +180,15 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. YAMLファイル作成
|
||||
const taskData: TaskFileData = { task: taskContent };
|
||||
if (worktree !== undefined) {
|
||||
taskData.worktree = worktree;
|
||||
}
|
||||
if (branch) {
|
||||
taskData.branch = branch;
|
||||
}
|
||||
if (piece) {
|
||||
taskData.piece = piece;
|
||||
}
|
||||
if (issueNumber !== undefined) {
|
||||
taskData.issue = issueNumber;
|
||||
}
|
||||
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
const yamlContent = stringifyYaml(taskData);
|
||||
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
||||
|
||||
log.info('Task created', { filePath, taskData });
|
||||
// 4. YAMLファイル作成
|
||||
const filePath = await saveTaskFile(cwd, taskContent, {
|
||||
piece,
|
||||
issue: issueNumber,
|
||||
worktree,
|
||||
branch,
|
||||
});
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
success(`Task created: ${filename}`);
|
||||
info(` Path: ${filePath}`);
|
||||
if (worktree) {
|
||||
|
||||
@ -14,7 +14,7 @@ export {
|
||||
type SelectAndExecuteOptions,
|
||||
type WorktreeConfirmationResult,
|
||||
} from './execute/selectAndExecute.js';
|
||||
export { addTask } from './add/index.js';
|
||||
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js';
|
||||
export { watchTasks } from './watch/index.js';
|
||||
export {
|
||||
listTasks,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* GitHub integration - barrel exports
|
||||
*/
|
||||
|
||||
export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult } from './types.js';
|
||||
export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult, CreateIssueOptions, CreateIssueResult } from './types.js';
|
||||
|
||||
export {
|
||||
checkGhCli,
|
||||
@ -11,6 +11,7 @@ export {
|
||||
parseIssueNumbers,
|
||||
isIssueReference,
|
||||
resolveIssueTask,
|
||||
createIssue,
|
||||
} from './issue.js';
|
||||
|
||||
export { pushBranch, createPullRequest, buildPrBody } from './pr.js';
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import type { GitHubIssue, GhCliStatus } from './types.js';
|
||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||
import type { GitHubIssue, GhCliStatus, CreateIssueOptions, CreateIssueResult } from './types.js';
|
||||
|
||||
export type { GitHubIssue, GhCliStatus };
|
||||
export type { GitHubIssue, GhCliStatus, CreateIssueOptions, CreateIssueResult };
|
||||
|
||||
const log = createLogger('github');
|
||||
|
||||
@ -172,3 +172,36 @@ export function resolveIssueTask(task: string): string {
|
||||
const issues = issueNumbers.map((n) => fetchIssue(n));
|
||||
return issues.map(formatIssueAsTask).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue via `gh issue create`.
|
||||
*/
|
||||
export function createIssue(options: CreateIssueOptions): CreateIssueResult {
|
||||
const ghStatus = checkGhCli();
|
||||
if (!ghStatus.available) {
|
||||
return { success: false, error: ghStatus.error };
|
||||
}
|
||||
|
||||
const args = ['issue', 'create', '--title', options.title, '--body', options.body];
|
||||
if (options.labels && options.labels.length > 0) {
|
||||
args.push('--label', options.labels.join(','));
|
||||
}
|
||||
|
||||
log.info('Creating issue', { title: options.title });
|
||||
|
||||
try {
|
||||
const output = execFileSync('gh', args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const url = output.trim();
|
||||
log.info('Issue created', { url });
|
||||
|
||||
return { success: true, url };
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
log.error('Issue creation failed', { error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,3 +35,20 @@ export interface CreatePrResult {
|
||||
/** Error message on failure */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CreateIssueOptions {
|
||||
/** Issue title */
|
||||
title: string;
|
||||
/** Issue body (markdown) */
|
||||
body: string;
|
||||
/** Labels to apply */
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface CreateIssueResult {
|
||||
success: boolean;
|
||||
/** Issue URL on success */
|
||||
url?: string;
|
||||
/** Error message on failure */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ -16,7 +16,12 @@ interactive:
|
||||
summarizeFailed: "Failed to summarize conversation. Please try again."
|
||||
continuePrompt: "Okay, continue describing your task."
|
||||
proposed: "Proposed task instruction:"
|
||||
confirm: "Use this task instruction?"
|
||||
actionPrompt: "What would you like to do?"
|
||||
actions:
|
||||
execute: "Execute now"
|
||||
createIssue: "Create GitHub Issue"
|
||||
saveTask: "Save as Task"
|
||||
continue: "Continue editing"
|
||||
cancelled: "Cancelled"
|
||||
playNoTask: "Please specify task content: /play <task>"
|
||||
previousTask:
|
||||
|
||||
@ -16,7 +16,12 @@ interactive:
|
||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
||||
continuePrompt: "続けてタスク内容を入力してください。"
|
||||
proposed: "提案されたタスク指示:"
|
||||
confirm: "このタスク指示で進めますか?"
|
||||
actionPrompt: "どうしますか?"
|
||||
actions:
|
||||
execute: "実行する"
|
||||
createIssue: "GitHub Issueを建てる"
|
||||
saveTask: "タスクにつむ"
|
||||
continue: "会話を続ける"
|
||||
cancelled: "キャンセルしました"
|
||||
playNoTask: "タスク内容を指定してください: /play <タスク内容>"
|
||||
previousTask:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user