progressをわかりやすくする

This commit is contained in:
nrslib 2026-02-10 21:44:42 +09:00
parent 79ee353990
commit 3fa99ae0f7
10 changed files with 106 additions and 52 deletions

View File

@ -18,6 +18,7 @@ vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(), info: vi.fn(),
blankLine: vi.fn(), blankLine: vi.fn(),
error: vi.fn(), error: vi.fn(),
withProgress: vi.fn(async (_start, _done, operation) => operation()),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(), info: vi.fn(),
error: vi.fn(), error: vi.fn(),
withProgress: vi.fn(async (_start, _done, operation) => operation()),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -28,14 +28,23 @@ vi.mock('../infra/task/summarize.js', () => ({
summarizeTaskName: vi.fn(), summarizeTaskName: vi.fn(),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => {
info: vi.fn(), const info = vi.fn();
error: vi.fn(), return {
success: vi.fn(), info,
header: vi.fn(), error: vi.fn(),
status: vi.fn(), success: vi.fn(),
setLogLevel: vi.fn(), header: vi.fn(),
})); status: vi.fn(),
setLogLevel: vi.fn(),
withProgress: vi.fn(async (start, done, operation) => {
info(start);
const result = await operation();
info(typeof done === 'function' ? done(result) : done);
return result;
}),
};
});
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),

View File

@ -40,14 +40,23 @@ vi.mock('../infra/task/summarize.js', async (importOriginal) => ({
summarizeTaskName: vi.fn(), summarizeTaskName: vi.fn(),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => {
header: vi.fn(), const info = vi.fn();
info: vi.fn(), return {
error: vi.fn(), header: vi.fn(),
success: vi.fn(), info,
status: vi.fn(), error: vi.fn(),
blankLine: vi.fn(), success: vi.fn(),
})); status: vi.fn(),
blankLine: vi.fn(),
withProgress: vi.fn(async (start, done, operation) => {
info(start);
const result = await operation();
info(typeof done === 'function' ? done(result) : done);
return result;
}),
};
});
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),

View File

@ -5,7 +5,7 @@
* pipeline mode, or interactive mode. * pipeline mode, or interactive mode.
*/ */
import { info, error } from '../../shared/ui/index.js'; import { info, error, 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';
@ -35,23 +35,24 @@ import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from '
* Returns resolved issues and the formatted task text for interactive mode. * Returns resolved issues and the formatted task text for interactive mode.
* Throws on gh CLI unavailability or fetch failure. * Throws on gh CLI unavailability or fetch failure.
*/ */
function resolveIssueInput( async function resolveIssueInput(
issueOption: number | undefined, issueOption: number | undefined,
task: string | undefined, task: string | undefined,
): { issues: GitHubIssue[]; initialInput: string } | null { ): Promise<{ issues: GitHubIssue[]; initialInput: string } | null> {
if (issueOption) { if (issueOption) {
info('Fetching GitHub Issue...');
const ghStatus = checkGhCli(); const ghStatus = checkGhCli();
if (!ghStatus.available) { if (!ghStatus.available) {
throw new Error(ghStatus.error); throw new Error(ghStatus.error);
} }
const issue = fetchIssue(issueOption); const issue = await withProgress(
info(`GitHub Issue fetched: #${issue.number} ${issue.title}`); 'Fetching GitHub Issue...',
(fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`,
async () => fetchIssue(issueOption),
);
return { issues: [issue], initialInput: formatIssueAsTask(issue) }; return { issues: [issue], initialInput: formatIssueAsTask(issue) };
} }
if (task && isDirectTask(task)) { if (task && isDirectTask(task)) {
info('Fetching GitHub Issue...');
const ghStatus = checkGhCli(); const ghStatus = checkGhCli();
if (!ghStatus.available) { if (!ghStatus.available) {
throw new Error(ghStatus.error); throw new Error(ghStatus.error);
@ -61,8 +62,11 @@ function resolveIssueInput(
if (issueNumbers.length === 0) { if (issueNumbers.length === 0) {
throw new Error(`Invalid issue reference: ${task}`); throw new Error(`Invalid issue reference: ${task}`);
} }
const issues = issueNumbers.map((n) => fetchIssue(n)); const issues = await withProgress(
info(`GitHub Issues fetched: ${issues.map((issue) => `#${issue.number}`).join(', ')}`); 'Fetching GitHub Issue...',
(fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`,
async () => issueNumbers.map((n) => fetchIssue(n)),
);
return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') };
} }
@ -118,7 +122,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
let initialInput: string | undefined = task; let initialInput: string | undefined = task;
try { try {
const issueResult = resolveIssueInput(opts.issue as number | undefined, task); const issueResult = await resolveIssueInput(opts.issue as number | undefined, task);
if (issueResult) { if (issueResult) {
selectOptions.issues = issueResult.issues; selectOptions.issues = issueResult.issues;
initialInput = issueResult.initialInput; initialInput = issueResult.initialInput;

View File

@ -7,7 +7,7 @@
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 } from '../../../shared/prompt/index.js';
import { success, info, error } from '../../../shared/ui/index.js'; import { success, info, error, withProgress } from '../../../shared/ui/index.js';
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; import { TaskRunner, type TaskFileData } 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';
@ -177,13 +177,16 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
if (isIssueReference(trimmedTask)) { if (isIssueReference(trimmedTask)) {
// Issue reference: fetch issue and use directly as task content // Issue reference: fetch issue and use directly as task content
info('Fetching GitHub Issue...');
try { try {
taskContent = resolveIssueTask(trimmedTask);
const numbers = parseIssueNumbers([trimmedTask]); const numbers = parseIssueNumbers([trimmedTask]);
const primaryIssueNumber = numbers[0];
taskContent = await withProgress(
'Fetching GitHub Issue...',
primaryIssueNumber ? `GitHub Issue fetched: #${primaryIssueNumber}` : 'GitHub Issue fetched',
async () => resolveIssueTask(trimmedTask),
);
if (numbers.length > 0) { if (numbers.length > 0) {
issueNumber = numbers[0]; issueNumber = numbers[0];
info(`GitHub Issue fetched: #${issueNumber}`);
} }
} catch (e) { } catch (e) {
const msg = getErrorMessage(e); const msg = getErrorMessage(e);

View File

@ -4,7 +4,7 @@
import { loadGlobalConfig } from '../../../infra/config/index.js'; import { loadGlobalConfig } from '../../../infra/config/index.js';
import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
import { info } from '../../../shared/ui/index.js'; import { info, withProgress } from '../../../shared/ui/index.js';
import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
export interface ResolvedTaskExecution { export interface ResolvedTaskExecution {
@ -60,23 +60,27 @@ export async function resolveTaskExecution(
if (data.worktree) { if (data.worktree) {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
baseBranch = getCurrentBranch(defaultCwd); baseBranch = getCurrentBranch(defaultCwd);
info('Generating branch name...'); const taskSlug = await withProgress(
const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); 'Generating branch name...',
info(`Branch name generated: ${taskSlug}`); (slug) => `Branch name generated: ${slug}`,
() => summarizeTaskName(task.content, { cwd: defaultCwd }),
);
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
info('Creating clone...'); const result = await withProgress(
const result = createSharedClone(defaultCwd, { 'Creating clone...',
worktree: data.worktree, (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`,
branch: data.branch, async () => createSharedClone(defaultCwd, {
taskSlug, worktree: data.worktree,
issueNumber: data.issue, branch: data.branch,
}); taskSlug,
issueNumber: data.issue,
}),
);
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
execCwd = result.path; execCwd = result.path;
branch = result.branch; branch = result.branch;
isWorktree = true; isWorktree = true;
info(`Clone created: ${result.path} (branch: ${result.branch})`);
} }
const execPiece = data.piece || defaultPiece; const execPiece = data.piece || defaultPiece;

View File

@ -19,7 +19,7 @@ import {
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { info, error, success } from '../../../shared/ui/index.js'; import { info, error, success, withProgress } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js'; import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
import { executeTask } from './taskExecution.js'; import { executeTask } from './taskExecution.js';
@ -113,16 +113,20 @@ export async function confirmAndCreateWorktree(
const baseBranch = getCurrentBranch(cwd); const baseBranch = getCurrentBranch(cwd);
info('Generating branch name...'); const taskSlug = await withProgress(
const taskSlug = await summarizeTaskName(task, { cwd }); 'Generating branch name...',
info(`Branch name generated: ${taskSlug}`); (slug) => `Branch name generated: ${slug}`,
() => summarizeTaskName(task, { cwd }),
);
info('Creating clone...'); const result = await withProgress(
const result = createSharedClone(cwd, { 'Creating clone...',
worktree: true, (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`,
taskSlug, async () => createSharedClone(cwd, {
}); worktree: true,
info(`Clone created: ${result.path} (branch: ${result.branch})`); taskSlug,
}),
);
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch };
} }

17
src/shared/ui/Progress.ts Normal file
View File

@ -0,0 +1,17 @@
import { info } from './LogManager.js';
export type ProgressCompletionMessage<T> = string | ((result: T) => string);
export async function withProgress<T>(
startMessage: string,
completionMessage: ProgressCompletionMessage<T>,
operation: () => Promise<T>,
): Promise<T> {
info(startMessage);
const result = await operation();
const message = typeof completionMessage === 'function'
? completionMessage(result)
: completionMessage;
info(message);
return result;
}

View File

@ -31,3 +31,5 @@ export { Spinner } from './Spinner.js';
export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js'; export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js';
export { TaskPrefixWriter } from './TaskPrefixWriter.js'; export { TaskPrefixWriter } from './TaskPrefixWriter.js';
export { withProgress, type ProgressCompletionMessage } from './Progress.js';