Merge pull request #377 from Yuma-Satake/feature/issue-111

Fix #111 Issue作成時にラベルを選択できるようにする
This commit is contained in:
Yuma Satake 2026-02-25 23:48:36 +09:00 committed by GitHub
parent 96ff2ed961
commit 6a175bcb11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 201 additions and 43 deletions

View File

@ -40,7 +40,8 @@ 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(),
promptLabelSelection: vi.fn().mockResolvedValue([]),
})); }));
vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/pipeline/index.js', () => ({
@ -105,7 +106,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, createIssueFromTask, saveTaskFromInteractive } 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 { resolveConfigValues, loadPersonaSessions } from '../infra/config/index.js'; import { resolveConfigValues, loadPersonaSessions } from '../infra/config/index.js';
import { isDirectTask } from '../app/cli/helpers.js'; import { isDirectTask } from '../app/cli/helpers.js';
@ -119,8 +120,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 mockCreateIssueFromTask = vi.mocked(createIssueFromTask); const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask);
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
const mockInteractiveMode = vi.mocked(interactiveMode); const mockInteractiveMode = vi.mocked(interactiveMode);
const mockLoadPersonaSessions = vi.mocked(loadPersonaSessions); const mockLoadPersonaSessions = vi.mocked(loadPersonaSessions);
const mockResolveConfigValues = vi.mocked(resolveConfigValues); const mockResolveConfigValues = vi.mocked(resolveConfigValues);
@ -435,38 +435,22 @@ describe('Issue resolution in routing', () => {
}); });
describe('create_issue action', () => { describe('create_issue action', () => {
it('should create issue first, then delegate final confirmation to saveTaskFromInteractive', async () => { it('should delegate to createIssueAndSaveTask with confirmAtEndMessage', async () => {
// Given // Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
mockCreateIssueFromTask.mockReturnValue(226);
// When // When
await executeDefaultAction(); await executeDefaultAction();
// Then: issue is created first // Then: issue is created first
expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith(
// Then: saveTaskFromInteractive receives final confirmation message
expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith(
'/test/cwd', '/test/cwd',
'New feature request', 'New feature request',
'default', 'default',
{ issue: 226, confirmAtEndMessage: 'Add this issue to tasks?' }, { confirmAtEndMessage: 'Add this issue to tasks?', labels: [] },
); );
}); });
it('should skip confirmation and task save when issue creation fails', async () => {
// Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
mockCreateIssueFromTask.mockReturnValue(undefined);
// When
await executeDefaultAction();
// Then
expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request');
expect(mockSaveTaskFromInteractive).not.toHaveBeenCalled();
});
it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { it('should not call selectAndExecuteTask when create_issue action is chosen', async () => {
// Given // Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });

View File

@ -62,23 +62,42 @@ describe('createIssue', () => {
]); ]);
}); });
it('should include labels when provided', () => { it('should include labels when provided and they exist on the repo', () => {
// Given // Given
mockExecFileSync mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status .mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('bug\npriority:high\n' as unknown as Buffer) // gh label list
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer); .mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
// When // When
createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'priority:high'] }); createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'priority:high'] });
// Then // Then
const issueCreateCall = mockExecFileSync.mock.calls[1]; const issueCreateCall = mockExecFileSync.mock.calls[2];
expect(issueCreateCall?.[1]).toEqual([ expect(issueCreateCall?.[1]).toEqual([
'issue', 'create', '--title', 'Bug', '--body', 'Fix it', 'issue', 'create', '--title', 'Bug', '--body', 'Fix it',
'--label', 'bug,priority:high', '--label', 'bug,priority:high',
]); ]);
}); });
it('should skip non-existent labels', () => {
// Given
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('bug\n' as unknown as Buffer) // gh label list (only bug exists)
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
// When
createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'Docs'] });
// Then
const issueCreateCall = mockExecFileSync.mock.calls[2];
expect(issueCreateCall?.[1]).toEqual([
'issue', 'create', '--title', 'Bug', '--body', 'Fix it',
'--label', 'bug',
]);
});
it('should not include --label when labels is empty', () => { it('should not include --label when labels is empty', () => {
// Given // Given
mockExecFileSync mockExecFileSync
@ -93,6 +112,21 @@ describe('createIssue', () => {
expect(issueCreateCall?.[1]).not.toContain('--label'); expect(issueCreateCall?.[1]).not.toContain('--label');
}); });
it('should not include --label when all labels are non-existent', () => {
// Given
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('bug\n' as unknown as Buffer) // gh label list
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
// When
createIssue({ title: 'Title', body: 'Body', labels: ['Docs', 'Chore'] });
// Then
const issueCreateCall = mockExecFileSync.mock.calls[2];
expect(issueCreateCall?.[1]).not.toContain('--label');
});
it('should return error when gh CLI is not authenticated', () => { it('should return error when gh CLI is not authenticated', () => {
// Given: auth fails, version succeeds // Given: auth fails, version succeeds
mockExecFileSync mockExecFileSync

View File

@ -164,4 +164,64 @@ describe('createIssueFromTask', () => {
body: task, body: task,
}); });
}); });
describe('labels option', () => {
it('should pass labels to createIssue when provided', () => {
// Given
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask('Test task', { labels: ['bug'] });
// Then
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'Test task',
body: 'Test task',
labels: ['bug'],
});
});
it('should not include labels key when options is undefined', () => {
// Given
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask('Test task');
// Then
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'Test task',
body: 'Test task',
});
});
it('should not include labels key when labels is empty array', () => {
// Given
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask('Test task', { labels: [] });
// Then
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'Test task',
body: 'Test task',
});
});
it('should filter out empty string labels', () => {
// Given
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask('Test task', { labels: ['bug', '', 'enhancement'] });
// Then
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'Test task',
body: 'Test task',
labels: ['bug', 'enhancement'],
});
});
});
}); });

View File

@ -9,7 +9,7 @@ import { info, error as logError, withProgress } 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, promptLabelSelection, 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,
@ -219,13 +219,11 @@ export async function executeDefaultAction(task?: string): Promise<void> {
await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides); await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides);
}, },
create_issue: async ({ task: confirmedTask }) => { create_issue: async ({ task: confirmedTask }) => {
const issueNumber = createIssueFromTask(confirmedTask); const labels = await promptLabelSelection(lang);
if (issueNumber !== undefined) { await createIssueAndSaveTask(resolvedCwd, confirmedTask, pieceId, {
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { confirmAtEndMessage: 'Add this issue to tasks?',
issue: issueNumber, labels,
confirmAtEndMessage: 'Add this issue to tasks?', });
});
}
}, },
save_task: async ({ task: confirmedTask }) => { save_task: async ({ task: confirmedTask }) => {
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId); await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId);

View File

@ -6,8 +6,10 @@
import * as path from 'node:path'; import * as path from 'node:path';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { promptInput, confirm, selectOption } from '../../../shared/prompt/index.js';
import { success, info, error, withProgress } from '../../../shared/ui/index.js'; import { success, info, error, withProgress } from '../../../shared/ui/index.js';
import { getLabel } from '../../../shared/i18n/index.js';
import type { Language } from '../../../core/models/types.js';
import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js'; import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js';
import { determinePiece } from '../execute/selectAndExecute.js'; import { determinePiece } from '../execute/selectAndExecute.js';
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
@ -73,14 +75,21 @@ 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): number | undefined { export function createIssueFromTask(task: string, options?: { labels?: string[] }): number | undefined {
info('Creating GitHub Issue...'); info('Creating GitHub Issue...');
const titleLine = task.split('\n')[0] || task; const titleLine = task.split('\n')[0] || task;
const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine; const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine;
const issueResult = createIssue({ title, body: task }); const effectiveLabels = options?.labels?.filter((l) => l.length > 0) ?? [];
const labels = effectiveLabels.length > 0 ? effectiveLabels : undefined;
const issueResult = createIssue({ title, body: task, labels });
if (issueResult.success) { if (issueResult.success) {
if (!issueResult.url) {
error('Failed to extract issue number from URL');
return undefined;
}
success(`Issue created: ${issueResult.url}`); success(`Issue created: ${issueResult.url}`);
const num = Number(issueResult.url!.split('/').pop()); const num = Number(issueResult.url.split('/').pop());
if (Number.isNaN(num)) { if (Number.isNaN(num)) {
error('Failed to extract issue number from URL'); error('Failed to extract issue number from URL');
return undefined; return undefined;
@ -127,13 +136,46 @@ function displayTaskCreationResult(
* Combines issue creation and task saving into a single workflow. * Combines issue creation and task saving into a single workflow.
* If issue creation fails, no task is saved. * If issue creation fails, no task is saved.
*/ */
export async function createIssueAndSaveTask(cwd: string, task: string, piece?: string): Promise<void> { export async function createIssueAndSaveTask(
const issueNumber = createIssueFromTask(task); cwd: string,
task: string,
piece?: string,
options?: { confirmAtEndMessage?: string; labels?: string[] },
): Promise<void> {
const issueNumber = createIssueFromTask(task, { labels: options?.labels });
if (issueNumber !== undefined) { if (issueNumber !== undefined) {
await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber }); await saveTaskFromInteractive(cwd, task, piece, {
issue: issueNumber,
confirmAtEndMessage: options?.confirmAtEndMessage,
});
} }
} }
/**
* Prompt user to select a label for the GitHub Issue.
*
* Presents 4 fixed options: None, bug, enhancement, custom input.
* Returns an array of selected labels (empty if none selected).
*/
export async function promptLabelSelection(lang: Language): Promise<string[]> {
const selected = await selectOption<string>(
getLabel('issue.labelSelection.prompt', lang),
[
{ label: getLabel('issue.labelSelection.none', lang), value: 'none' },
{ label: 'bug', value: 'bug' },
{ label: 'enhancement', value: 'enhancement' },
{ label: getLabel('issue.labelSelection.custom', lang), value: 'custom' },
],
);
if (selected === null || selected === 'none') return [];
if (selected === 'custom') {
const customLabel = await promptInput(getLabel('issue.labelSelection.customPrompt', lang));
return customLabel?.split(',').map((l) => l.trim()).filter((l) => l.length > 0) ?? [];
}
return [selected];
}
async function promptWorktreeSettings(): Promise<WorktreeSettings> { async function promptWorktreeSettings(): Promise<WorktreeSettings> {
const customPath = await promptInput('Worktree path (Enter for auto)'); const customPath = await promptInput('Worktree path (Enter for auto)');
const worktree: boolean | string = customPath || true; const worktree: boolean | string = customPath || true;

View File

@ -16,7 +16,7 @@ export {
type WorktreeConfirmationResult, type WorktreeConfirmationResult,
} from './execute/selectAndExecute.js'; } from './execute/selectAndExecute.js';
export { resolveAutoPr, postExecutionFlow, type PostExecutionOptions } from './execute/postExecution.js'; export { resolveAutoPr, postExecutionFlow, type PostExecutionOptions } from './execute/postExecution.js';
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js'; export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask, promptLabelSelection } from './add/index.js';
export { watchTasks } from './watch/index.js'; export { watchTasks } from './watch/index.js';
export { export {
listTasks, listTasks,

View File

@ -174,7 +174,28 @@ export function resolveIssueTask(task: string): string {
} }
/** /**
* Create a GitHub Issue via `gh issue create`. * Filter labels to only those that exist on the repository.
*/
function filterExistingLabels(labels: string[]): string[] {
try {
const existing = new Set(
execFileSync('gh', ['label', 'list', '--json', 'name', '-q', '.[].name'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
})
.trim()
.split('\n')
.filter((l) => l.length > 0),
);
return labels.filter((l) => existing.has(l));
} catch (err) {
log.error('Failed to fetch labels', { error: getErrorMessage(err) });
return [];
}
}
/**
* Create a GitHub Issue via `gh issue create`.
*/ */
export function createIssue(options: CreateIssueOptions): CreateIssueResult { export function createIssue(options: CreateIssueOptions): CreateIssueResult {
const ghStatus = checkGhCli(); const ghStatus = checkGhCli();
@ -184,7 +205,10 @@ export function createIssue(options: CreateIssueOptions): CreateIssueResult {
const args = ['issue', 'create', '--title', options.title, '--body', options.body]; const args = ['issue', 'create', '--title', options.title, '--body', options.body];
if (options.labels && options.labels.length > 0) { if (options.labels && options.labels.length > 0) {
args.push('--label', options.labels.join(',')); const validLabels = filterExistingLabels(options.labels);
if (validLabels.length > 0) {
args.push('--label', validLabels.join(','));
}
} }
log.info('Creating issue', { title: options.title }); log.info('Creating issue', { title: options.title });

View File

@ -103,3 +103,11 @@ retry:
run: run:
notifyComplete: "Run complete ({total} tasks)" notifyComplete: "Run complete ({total} tasks)"
notifyAbort: "Run finished with errors ({failed})" notifyAbort: "Run finished with errors ({failed})"
# ===== Issue Creation UI =====
issue:
labelSelection:
prompt: "Label:"
none: "None"
custom: "Custom input"
customPrompt: "Enter label name"

View File

@ -103,3 +103,11 @@ retry:
run: run:
notifyComplete: "run完了 ({total} tasks)" notifyComplete: "run完了 ({total} tasks)"
notifyAbort: "runはエラー終了 ({failed})" notifyAbort: "runはエラー終了 ({failed})"
# ===== Issue Creation UI =====
issue:
labelSelection:
prompt: "ラベル:"
none: "なし"
custom: "入力(カスタム)"
customPrompt: "ラベル名を入力"