Merge pull request #377 from Yuma-Satake/feature/issue-111
Fix #111 Issue作成時にラベルを選択できるようにする
This commit is contained in:
parent
96ff2ed961
commit
6a175bcb11
@ -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' });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<void> {
|
||||
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);
|
||||
|
||||
@ -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<void> {
|
||||
const issueNumber = createIssueFromTask(task);
|
||||
export async function createIssueAndSaveTask(
|
||||
cwd: string,
|
||||
task: string,
|
||||
piece?: string,
|
||||
options?: { confirmAtEndMessage?: string; labels?: string[] },
|
||||
): Promise<void> {
|
||||
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<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> {
|
||||
const customPath = await promptInput('Worktree path (Enter for auto)');
|
||||
const worktree: boolean | string = customPath || true;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -173,6 +173,27 @@ export function resolveIssueTask(task: string): string {
|
||||
return issues.map(formatIssueAsTask).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
@ -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 });
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -103,3 +103,11 @@ retry:
|
||||
run:
|
||||
notifyComplete: "run完了 ({total} tasks)"
|
||||
notifyAbort: "runはエラー終了 ({failed})"
|
||||
|
||||
# ===== Issue Creation UI =====
|
||||
issue:
|
||||
labelSelection:
|
||||
prompt: "ラベル:"
|
||||
none: "なし"
|
||||
custom: "入力(カスタム)"
|
||||
customPrompt: "ラベル名を入力"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user