createPullRequest の全呼び出し箇所で base が未指定だったため、 PR が常にリポジトリデフォルトブランチ(main)向けに作成されていた。 ブランチ作成/clone作成の直前に getCurrentBranch() で元ブランチを 取得し、PR作成時に base として渡すように修正。
466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
/**
|
|
* Tests for pipeline execution
|
|
*
|
|
* Tests the orchestration logic with mocked dependencies.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock all external dependencies
|
|
const mockFetchIssue = vi.fn();
|
|
const mockCheckGhCli = vi.fn().mockReturnValue({ available: true });
|
|
vi.mock('../infra/github/issue.js', () => ({
|
|
fetchIssue: mockFetchIssue,
|
|
formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) =>
|
|
`## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}`
|
|
),
|
|
checkGhCli: mockCheckGhCli,
|
|
}));
|
|
|
|
const mockCreatePullRequest = vi.fn();
|
|
const mockPushBranch = vi.fn();
|
|
const mockBuildPrBody = vi.fn(() => 'Default PR body');
|
|
vi.mock('../infra/github/pr.js', () => ({
|
|
createPullRequest: mockCreatePullRequest,
|
|
pushBranch: mockPushBranch,
|
|
buildPrBody: mockBuildPrBody,
|
|
}));
|
|
|
|
const mockExecuteTask = vi.fn();
|
|
vi.mock('../features/tasks/index.js', () => ({
|
|
executeTask: mockExecuteTask,
|
|
}));
|
|
|
|
// Mock loadGlobalConfig
|
|
const mockLoadGlobalConfig = vi.fn();
|
|
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
|
|
loadGlobalConfig: mockLoadGlobalConfig,
|
|
}));
|
|
|
|
// Mock execFileSync for git operations
|
|
const mockExecFileSync = vi.fn();
|
|
vi.mock('node:child_process', () => ({
|
|
execFileSync: mockExecFileSync,
|
|
}));
|
|
|
|
// Mock UI
|
|
vi.mock('../shared/ui/index.js', () => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
status: vi.fn(),
|
|
blankLine: vi.fn(),
|
|
header: vi.fn(),
|
|
section: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
}));
|
|
// Mock debug logger
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const { executePipeline } = await import('../features/pipeline/index.js');
|
|
|
|
describe('executePipeline', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default: git operations succeed
|
|
mockExecFileSync.mockReturnValue('abc1234\n');
|
|
// Default: no pipeline config
|
|
mockLoadGlobalConfig.mockReturnValue({
|
|
language: 'en',
|
|
defaultPiece: 'default',
|
|
logLevel: 'info',
|
|
provider: 'claude',
|
|
});
|
|
});
|
|
|
|
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
|
|
const exitCode = await executePipeline({
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
});
|
|
|
|
it('should return exit code 2 when gh CLI is not available', async () => {
|
|
mockCheckGhCli.mockReturnValueOnce({ available: false, error: 'gh not found' });
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 99,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
});
|
|
|
|
it('should return exit code 2 when issue fetch fails', async () => {
|
|
mockFetchIssue.mockImplementationOnce(() => {
|
|
throw new Error('Issue not found');
|
|
});
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 999,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
});
|
|
|
|
it('should return exit code 3 when piece fails', async () => {
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 99,
|
|
title: 'Test issue',
|
|
body: 'Test body',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(false);
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 99,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(3);
|
|
});
|
|
|
|
it('should return exit code 0 on successful task-only execution', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
it('passes provider/model overrides to task execution', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
provider: 'codex',
|
|
model: 'codex-model',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: { provider: 'codex', model: 'codex-model' },
|
|
});
|
|
});
|
|
|
|
it('should return exit code 5 when PR creation fails', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: false, error: 'PR failed' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(5);
|
|
});
|
|
|
|
it('should create PR with correct branch when --auto-pr', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: true,
|
|
repo: 'owner/repo',
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
branch: 'fix/my-branch',
|
|
repo: 'owner/repo',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass baseBranch as base to createPullRequest', async () => {
|
|
// Given: getCurrentBranch returns 'develop' before branch creation
|
|
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
|
return 'develop\n';
|
|
}
|
|
return 'abc1234\n';
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
// When
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Then
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
branch: 'fix/my-branch',
|
|
base: 'develop',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use --task when both --task and positional task are provided', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'From --task flag',
|
|
piece: 'magi',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'From --task flag',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'magi',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
describe('PipelineConfig template expansion', () => {
|
|
it('should use commit_message_template when configured', async () => {
|
|
mockLoadGlobalConfig.mockReturnValue({
|
|
language: 'en',
|
|
defaultPiece: 'default',
|
|
logLevel: 'info',
|
|
provider: 'claude',
|
|
pipeline: {
|
|
commitMessageTemplate: 'fix: {title} (#{issue})',
|
|
},
|
|
});
|
|
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 42,
|
|
title: 'Login broken',
|
|
body: 'Cannot login.',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
await executePipeline({
|
|
issueNumber: 42,
|
|
piece: 'default',
|
|
branch: 'test-branch',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Verify commit was called with expanded template
|
|
const commitCall = mockExecFileSync.mock.calls.find(
|
|
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'commit',
|
|
);
|
|
expect(commitCall).toBeDefined();
|
|
expect((commitCall![1] as string[])[2]).toBe('fix: Login broken (#42)');
|
|
});
|
|
|
|
it('should use default_branch_prefix when configured', async () => {
|
|
mockLoadGlobalConfig.mockReturnValue({
|
|
language: 'en',
|
|
defaultPiece: 'default',
|
|
logLevel: 'info',
|
|
provider: 'claude',
|
|
pipeline: {
|
|
defaultBranchPrefix: 'feat/',
|
|
},
|
|
});
|
|
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 10,
|
|
title: 'Add feature',
|
|
body: 'Please add.',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
await executePipeline({
|
|
issueNumber: 10,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Verify checkout -b was called with prefix
|
|
const checkoutCall = mockExecFileSync.mock.calls.find(
|
|
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'checkout' && (call[1] as string[])[1] === '-b',
|
|
);
|
|
expect(checkoutCall).toBeDefined();
|
|
const branchName = (checkoutCall![1] as string[])[2];
|
|
expect(branchName).toMatch(/^feat\/issue-10-\d+$/);
|
|
});
|
|
|
|
it('should use pr_body_template when configured for PR creation', async () => {
|
|
mockLoadGlobalConfig.mockReturnValue({
|
|
language: 'en',
|
|
defaultPiece: 'default',
|
|
logLevel: 'info',
|
|
provider: 'claude',
|
|
pipeline: {
|
|
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
|
|
},
|
|
});
|
|
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 50,
|
|
title: 'Fix auth',
|
|
body: 'Auth is broken.',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' });
|
|
|
|
await executePipeline({
|
|
issueNumber: 50,
|
|
piece: 'default',
|
|
branch: 'fix-auth',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// When prBodyTemplate is set, buildPrBody (mock) should NOT be called
|
|
// Instead, the template is expanded directly
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
body: '## Summary\nAuth is broken.\n\nCloses #50',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should fall back to buildPrBody when no template is configured', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' });
|
|
|
|
await executePipeline({
|
|
task: 'Fix bug',
|
|
piece: 'default',
|
|
branch: 'fix-branch',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Should use buildPrBody (the mock)
|
|
expect(mockBuildPrBody).toHaveBeenCalled();
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
body: 'Default PR body',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('--skip-git', () => {
|
|
it('should skip branch creation, commit, push when skipGit is true', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
|
|
// No git operations should have been called
|
|
const gitCalls = mockExecFileSync.mock.calls.filter(
|
|
(call: unknown[]) => call[0] === 'git',
|
|
);
|
|
expect(gitCalls).toHaveLength(0);
|
|
expect(mockPushBranch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore --auto-pr when skipGit is true', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: true,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should still return piece failure exit code when skipGit is true', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(false);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(3);
|
|
});
|
|
});
|
|
});
|