From 6a175bcb11da11b2dba9d26fc261c461811b0c19 Mon Sep 17 00:00:00 2001 From: Yuma Satake Date: Wed, 25 Feb 2026 23:48:36 +0900 Subject: [PATCH] Merge pull request #377 from Yuma-Satake/feature/issue-111 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #111 Issue作成時にラベルを選択できるようにする --- .../cli-routing-issue-resolve.test.ts | 30 +++------- src/__tests__/createIssue.test.ts | 38 +++++++++++- src/__tests__/createIssueFromTask.test.ts | 60 +++++++++++++++++++ src/app/cli/routing.ts | 14 ++--- src/features/tasks/add/index.ts | 56 ++++++++++++++--- src/features/tasks/index.ts | 2 +- src/infra/github/issue.ts | 28 ++++++++- src/shared/i18n/labels_en.yaml | 8 +++ src/shared/i18n/labels_ja.yaml | 8 +++ 9 files changed, 201 insertions(+), 43 deletions(-) diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 7eed4f1..ecacde6 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -40,7 +40,8 @@ vi.mock('../features/tasks/index.js', () => ({ selectAndExecuteTask: vi.fn(), determinePiece: vi.fn(), saveTaskFromInteractive: vi.fn(), - createIssueFromTask: vi.fn(), + createIssueAndSaveTask: vi.fn(), + promptLabelSelection: vi.fn().mockResolvedValue([]), })); 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 { 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 { resolveConfigValues, loadPersonaSessions } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; @@ -119,8 +120,7 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); -const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); -const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); +const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockLoadPersonaSessions = vi.mocked(loadPersonaSessions); const mockResolveConfigValues = vi.mocked(resolveConfigValues); @@ -435,38 +435,22 @@ describe('Issue resolution in routing', () => { }); 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 mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); - mockCreateIssueFromTask.mockReturnValue(226); // When await executeDefaultAction(); // Then: issue is created first - expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); - // Then: saveTaskFromInteractive receives final confirmation message - expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith( + expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith( '/test/cwd', 'New feature request', '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 () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); diff --git a/src/__tests__/createIssue.test.ts b/src/__tests__/createIssue.test.ts index 3f1f05b..6838808 100644 --- a/src/__tests__/createIssue.test.ts +++ b/src/__tests__/createIssue.test.ts @@ -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 mockExecFileSync .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); // When createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'priority:high'] }); // Then - const issueCreateCall = mockExecFileSync.mock.calls[1]; + const issueCreateCall = mockExecFileSync.mock.calls[2]; expect(issueCreateCall?.[1]).toEqual([ 'issue', 'create', '--title', 'Bug', '--body', 'Fix it', '--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', () => { // Given mockExecFileSync @@ -93,6 +112,21 @@ describe('createIssue', () => { 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', () => { // Given: auth fails, version succeeds mockExecFileSync diff --git a/src/__tests__/createIssueFromTask.test.ts b/src/__tests__/createIssueFromTask.test.ts index e31d3ed..d8fb7de 100644 --- a/src/__tests__/createIssueFromTask.test.ts +++ b/src/__tests__/createIssueFromTask.test.ts @@ -164,4 +164,64 @@ describe('createIssueFromTask', () => { 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'], + }); + }); + }); }); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 7adcd5c..0a44b38 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -9,7 +9,7 @@ import { info, error as logError, withProgress } 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, promptLabelSelection, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, @@ -219,13 +219,11 @@ export async function executeDefaultAction(task?: string): Promise { await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides); }, create_issue: async ({ task: confirmedTask }) => { - const issueNumber = createIssueFromTask(confirmedTask); - if (issueNumber !== undefined) { - await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { - issue: issueNumber, - confirmAtEndMessage: 'Add this issue to tasks?', - }); - } + const labels = await promptLabelSelection(lang); + await createIssueAndSaveTask(resolvedCwd, confirmedTask, pieceId, { + confirmAtEndMessage: 'Add this issue to tasks?', + labels, + }); }, save_task: async ({ task: confirmedTask }) => { await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index e21bbe9..1e71176 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -6,8 +6,10 @@ import * as path from 'node:path'; 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 { 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 { determinePiece } from '../execute/selectAndExecute.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), * 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...'); const titleLine = task.split('\n')[0] || task; 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.url) { + error('Failed to extract issue number from URL'); + return undefined; + } success(`Issue created: ${issueResult.url}`); - const num = Number(issueResult.url!.split('/').pop()); + const num = Number(issueResult.url.split('/').pop()); if (Number.isNaN(num)) { error('Failed to extract issue number from URL'); return undefined; @@ -127,13 +136,46 @@ function displayTaskCreationResult( * 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 { - const issueNumber = createIssueFromTask(task); +export async function createIssueAndSaveTask( + cwd: string, + task: string, + piece?: string, + options?: { confirmAtEndMessage?: string; labels?: string[] }, +): Promise { + const issueNumber = createIssueFromTask(task, { labels: options?.labels }); 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 { + const selected = await selectOption( + 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 { const customPath = await promptInput('Worktree path (Enter for auto)'); const worktree: boolean | string = customPath || true; diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index 90ec8f8..c2b5f54 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -16,7 +16,7 @@ export { type WorktreeConfirmationResult, } from './execute/selectAndExecute.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 { listTasks, diff --git a/src/infra/github/issue.ts b/src/infra/github/issue.ts index 7f55ba9..fac2f5b 100644 --- a/src/infra/github/issue.ts +++ b/src/infra/github/issue.ts @@ -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 { const ghStatus = checkGhCli(); @@ -184,7 +205,10 @@ export function createIssue(options: CreateIssueOptions): CreateIssueResult { const args = ['issue', 'create', '--title', options.title, '--body', options.body]; 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 }); diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 07662c6..b578413 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -103,3 +103,11 @@ retry: run: notifyComplete: "Run complete ({total} tasks)" notifyAbort: "Run finished with errors ({failed})" + +# ===== Issue Creation UI ===== +issue: + labelSelection: + prompt: "Label:" + none: "None" + custom: "Custom input" + customPrompt: "Enter label name" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 51e8c3c..b22e1b1 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -103,3 +103,11 @@ retry: run: notifyComplete: "run完了 ({total} tasks)" notifyAbort: "runはエラー終了 ({failed})" + +# ===== Issue Creation UI ===== +issue: + labelSelection: + prompt: "ラベル:" + none: "なし" + custom: "入力(カスタム)" + customPrompt: "ラベル名を入力"