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 { interactiveMode } from '../features/interactive/index.js';
|
||||||
import { promptInput, confirm } from '../shared/prompt/index.js';
|
import { promptInput, confirm } from '../shared/prompt/index.js';
|
||||||
import { determinePiece } from '../features/tasks/execute/selectAndExecute.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';
|
import { addTask } from '../features/tasks/index.js';
|
||||||
|
|
||||||
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
||||||
@ -74,6 +74,7 @@ const mockInteractiveMode = vi.mocked(interactiveMode);
|
|||||||
const mockPromptInput = vi.mocked(promptInput);
|
const mockPromptInput = vi.mocked(promptInput);
|
||||||
const mockConfirm = vi.mocked(confirm);
|
const mockConfirm = vi.mocked(confirm);
|
||||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||||
|
const mockCreateIssue = vi.mocked(createIssue);
|
||||||
|
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
@ -138,4 +139,32 @@ describe('addTask', () => {
|
|||||||
|
|
||||||
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
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(),
|
selectAndExecuteTask: vi.fn(),
|
||||||
determinePiece: vi.fn(),
|
determinePiece: vi.fn(),
|
||||||
saveTaskFromInteractive: vi.fn(),
|
saveTaskFromInteractive: vi.fn(),
|
||||||
createIssueFromTask: vi.fn(),
|
createIssueAndSaveTask: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/pipeline/index.js', () => ({
|
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 { 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 { interactiveMode } from '../features/interactive/index.js';
|
||||||
import { isDirectTask } from '../app/cli/helpers.js';
|
import { isDirectTask } from '../app/cli/helpers.js';
|
||||||
import { executeDefaultAction } from '../app/cli/routing.js';
|
import { executeDefaultAction } from '../app/cli/routing.js';
|
||||||
@ -95,6 +95,7 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask);
|
|||||||
const mockParseIssueNumbers = vi.mocked(parseIssueNumbers);
|
const mockParseIssueNumbers = vi.mocked(parseIssueNumbers);
|
||||||
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
||||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||||
|
const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask);
|
||||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||||
const mockIsDirectTask = vi.mocked(isDirectTask);
|
const mockIsDirectTask = vi.mocked(isDirectTask);
|
||||||
|
|
||||||
@ -261,4 +262,32 @@ describe('Issue resolution in routing', () => {
|
|||||||
expect(mockSelectAndExecuteTask).not.toHaveBeenCalled();
|
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();
|
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', () => {
|
it('should use first line as title and full text as body for multi-line task', () => {
|
||||||
// Given: multi-line task
|
// Given: multi-line task
|
||||||
const task = 'First line title\nSecond line details\nThird line more info';
|
const task = 'First line title\nSecond line details\nThird line more info';
|
||||||
|
|||||||
@ -122,4 +122,16 @@ describe('saveTaskFromInteractive', () => {
|
|||||||
|
|
||||||
expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
|
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 { getErrorMessage } from '../../shared/utils/index.js';
|
||||||
import { getLabel } from '../../shared/i18n/index.js';
|
import { getLabel } from '../../shared/i18n/index.js';
|
||||||
import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/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 { executePipeline } from '../../features/pipeline/index.js';
|
||||||
import {
|
import {
|
||||||
interactiveMode,
|
interactiveMode,
|
||||||
@ -188,7 +188,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'create_issue':
|
case 'create_issue':
|
||||||
createIssueFromTask(result.task);
|
await createIssueAndSaveTask(resolvedCwd, result.task, pieceId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'save_task':
|
case 'save_task':
|
||||||
|
|||||||
@ -48,15 +48,22 @@ export async function saveTaskFile(
|
|||||||
* Extracts the first line as the issue title (truncated to 100 chars),
|
* Extracts the first line as the issue title (truncated to 100 chars),
|
||||||
* uses the full task as the body, and displays success/error messages.
|
* 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...');
|
info('Creating GitHub Issue...');
|
||||||
const firstLine = task.split('\n')[0] || task;
|
const firstLine = task.split('\n')[0] || task;
|
||||||
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
|
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
|
||||||
const issueResult = createIssue({ title, body: task });
|
const issueResult = createIssue({ title, body: task });
|
||||||
if (issueResult.success) {
|
if (issueResult.success) {
|
||||||
success(`Issue created: ${issueResult.url}`);
|
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 {
|
} else {
|
||||||
error(`Failed to create issue: ${issueResult.error}`);
|
error(`Failed to create issue: ${issueResult.error}`);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,6 +73,38 @@ interface WorktreeSettings {
|
|||||||
autoPr?: boolean;
|
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> {
|
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
|
||||||
const useWorktree = await confirm('Create worktree?', true);
|
const useWorktree = await confirm('Create worktree?', true);
|
||||||
if (!useWorktree) {
|
if (!useWorktree) {
|
||||||
@ -91,21 +130,11 @@ export async function saveTaskFromInteractive(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
task: string,
|
task: string,
|
||||||
piece?: string,
|
piece?: string,
|
||||||
|
options?: { issue?: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const settings = await promptWorktreeSettings();
|
const settings = await promptWorktreeSettings();
|
||||||
const created = await saveTaskFile(cwd, task, { piece, ...settings });
|
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
|
||||||
success(`Task created: ${created.taskName}`);
|
displayTaskCreationResult(created, settings, piece);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -161,7 +190,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
|||||||
const result = await interactiveMode(cwd, undefined, pieceContext);
|
const result = await interactiveMode(cwd, undefined, pieceContext);
|
||||||
|
|
||||||
if (result.action === 'create_issue') {
|
if (result.action === 'create_issue') {
|
||||||
createIssueFromTask(result.task);
|
await createIssueAndSaveTask(cwd, result.task, piece);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,18 +213,5 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
|||||||
...settings,
|
...settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
success(`Task created: ${created.taskName}`);
|
displayTaskCreationResult(created, settings, piece);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export {
|
|||||||
type SelectAndExecuteOptions,
|
type SelectAndExecuteOptions,
|
||||||
type WorktreeConfirmationResult,
|
type WorktreeConfirmationResult,
|
||||||
} from './execute/selectAndExecute.js';
|
} 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 { watchTasks } from './watch/index.js';
|
||||||
export {
|
export {
|
||||||
listTasks,
|
listTasks,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user