takt: github-issue-193-takt-add-issue (#199)
This commit is contained in:
parent
c7c50db46a
commit
f8b9d4607f
@ -66,7 +66,7 @@ vi.mock('../infra/github/issue.js', () => ({
|
||||
import { interactiveMode } from '../features/interactive/index.js';
|
||||
import { promptInput, confirm } from '../shared/prompt/index.js';
|
||||
import { determinePiece } from '../features/tasks/execute/selectAndExecute.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);
|
||||
@ -74,6 +74,7 @@ const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockPromptInput = vi.mocked(promptInput);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||
const mockCreateIssue = vi.mocked(createIssue);
|
||||
|
||||
let testDir: string;
|
||||
|
||||
@ -138,4 +139,32 @@ describe('addTask', () => {
|
||||
|
||||
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should create issue and save task when create_issue action is chosen', async () => {
|
||||
// Given
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/55' });
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const tasks = loadTasks(testDir).tasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.issue).toBe(55);
|
||||
expect(tasks[0]?.content).toContain('New feature');
|
||||
});
|
||||
|
||||
it('should not save task when issue creation fails in create_issue action', async () => {
|
||||
// Given
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' });
|
||||
mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -36,7 +36,7 @@ vi.mock('../features/tasks/index.js', () => ({
|
||||
selectAndExecuteTask: vi.fn(),
|
||||
determinePiece: vi.fn(),
|
||||
saveTaskFromInteractive: vi.fn(),
|
||||
createIssueFromTask: vi.fn(),
|
||||
createIssueAndSaveTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../features/pipeline/index.js', () => ({
|
||||
@ -83,7 +83,7 @@ vi.mock('../app/cli/helpers.js', () => ({
|
||||
}));
|
||||
|
||||
import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js';
|
||||
import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js';
|
||||
import { interactiveMode } from '../features/interactive/index.js';
|
||||
import { isDirectTask } from '../app/cli/helpers.js';
|
||||
import { executeDefaultAction } from '../app/cli/routing.js';
|
||||
@ -95,6 +95,7 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask);
|
||||
const mockParseIssueNumbers = vi.mocked(parseIssueNumbers);
|
||||
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||
const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask);
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockIsDirectTask = vi.mocked(isDirectTask);
|
||||
|
||||
@ -261,4 +262,32 @@ describe('Issue resolution in routing', () => {
|
||||
expect(mockSelectAndExecuteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create_issue action', () => {
|
||||
it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId', async () => {
|
||||
// Given
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
|
||||
|
||||
// When
|
||||
await executeDefaultAction();
|
||||
|
||||
// Then: createIssueAndSaveTask should be called with correct args
|
||||
expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
'New feature request',
|
||||
'default',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call selectAndExecuteTask when create_issue action is chosen', async () => {
|
||||
// Given
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
|
||||
|
||||
// When
|
||||
await executeDefaultAction();
|
||||
|
||||
// Then: selectAndExecuteTask should NOT be called
|
||||
expect(mockSelectAndExecuteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -114,6 +114,42 @@ describe('createIssueFromTask', () => {
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('return value', () => {
|
||||
it('should return issue number when creation succeeds', () => {
|
||||
// Given
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/42' });
|
||||
|
||||
// When
|
||||
const result = createIssueFromTask('Test task');
|
||||
|
||||
// Then
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should return undefined when creation fails', () => {
|
||||
// Given
|
||||
mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' });
|
||||
|
||||
// When
|
||||
const result = createIssueFromTask('Test task');
|
||||
|
||||
// Then
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined and display error when URL has non-numeric suffix', () => {
|
||||
// Given
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/abc' });
|
||||
|
||||
// When
|
||||
const result = createIssueFromTask('Test task');
|
||||
|
||||
// Then
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockError).toHaveBeenCalledWith('Failed to extract issue number from URL');
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
@ -122,4 +122,16 @@ describe('saveTaskFromInteractive', () => {
|
||||
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
|
||||
});
|
||||
|
||||
it('should record issue number in tasks.yaml when issue option is provided', async () => {
|
||||
// Given: user declines worktree
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 });
|
||||
|
||||
// Then
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.issue).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,7 +9,7 @@ import { info, error } from '../../shared/ui/index.js';
|
||||
import { getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
|
||||
import { executePipeline } from '../../features/pipeline/index.js';
|
||||
import {
|
||||
interactiveMode,
|
||||
@ -188,7 +188,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
break;
|
||||
|
||||
case 'create_issue':
|
||||
createIssueFromTask(result.task);
|
||||
await createIssueAndSaveTask(resolvedCwd, result.task, pieceId);
|
||||
break;
|
||||
|
||||
case 'save_task':
|
||||
|
||||
@ -48,15 +48,22 @@ export async function saveTaskFile(
|
||||
* 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 {
|
||||
export function createIssueFromTask(task: string): number | undefined {
|
||||
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}`);
|
||||
const num = Number(issueResult.url!.split('/').pop());
|
||||
if (Number.isNaN(num)) {
|
||||
error('Failed to extract issue number from URL');
|
||||
return undefined;
|
||||
}
|
||||
return num;
|
||||
} else {
|
||||
error(`Failed to create issue: ${issueResult.error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,6 +73,38 @@ interface WorktreeSettings {
|
||||
autoPr?: boolean;
|
||||
}
|
||||
|
||||
function displayTaskCreationResult(
|
||||
created: { taskName: string; tasksFile: string },
|
||||
settings: WorktreeSettings,
|
||||
piece?: string,
|
||||
): void {
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
if (settings.branch) {
|
||||
info(` Branch: ${settings.branch}`);
|
||||
}
|
||||
if (settings.autoPr) {
|
||||
info(` Auto-PR: yes`);
|
||||
}
|
||||
if (piece) info(` Piece: ${piece}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue and save the task to .takt/tasks.yaml.
|
||||
*
|
||||
* Combines issue creation and task saving into a single workflow.
|
||||
* If issue creation fails, no task is saved.
|
||||
*/
|
||||
export async function createIssueAndSaveTask(cwd: string, task: string, piece?: string): Promise<void> {
|
||||
const issueNumber = createIssueFromTask(task);
|
||||
if (issueNumber !== undefined) {
|
||||
await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber });
|
||||
}
|
||||
}
|
||||
|
||||
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
|
||||
const useWorktree = await confirm('Create worktree?', true);
|
||||
if (!useWorktree) {
|
||||
@ -91,21 +130,11 @@ export async function saveTaskFromInteractive(
|
||||
cwd: string,
|
||||
task: string,
|
||||
piece?: string,
|
||||
options?: { issue?: number },
|
||||
): Promise<void> {
|
||||
const settings = await promptWorktreeSettings();
|
||||
const created = await saveTaskFile(cwd, task, { piece, ...settings });
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
if (settings.branch) {
|
||||
info(` Branch: ${settings.branch}`);
|
||||
}
|
||||
if (settings.autoPr) {
|
||||
info(` Auto-PR: yes`);
|
||||
}
|
||||
if (piece) info(` Piece: ${piece}`);
|
||||
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
|
||||
displayTaskCreationResult(created, settings, piece);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -161,7 +190,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
const result = await interactiveMode(cwd, undefined, pieceContext);
|
||||
|
||||
if (result.action === 'create_issue') {
|
||||
createIssueFromTask(result.task);
|
||||
await createIssueAndSaveTask(cwd, result.task, piece);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -184,18 +213,5 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
...settings,
|
||||
});
|
||||
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
if (settings.branch) {
|
||||
info(` Branch: ${settings.branch}`);
|
||||
}
|
||||
if (settings.autoPr) {
|
||||
info(` Auto-PR: yes`);
|
||||
}
|
||||
if (piece) {
|
||||
info(` Piece: ${piece}`);
|
||||
}
|
||||
displayTaskCreationResult(created, settings, piece);
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ export {
|
||||
type SelectAndExecuteOptions,
|
||||
type WorktreeConfirmationResult,
|
||||
} from './execute/selectAndExecute.js';
|
||||
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js';
|
||||
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js';
|
||||
export { watchTasks } from './watch/index.js';
|
||||
export {
|
||||
listTasks,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user