328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import { spawn } from 'child_process';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import type {
|
|
ChangelogGenerationRequest,
|
|
ChangelogGenerationResult,
|
|
ChangelogGenerationProgress,
|
|
TaskSpecContent
|
|
} from '../../shared/types';
|
|
import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './formatter';
|
|
import { extractChangelog } from './parser';
|
|
import { getCommits, getBranchDiffCommits } from './git-integration';
|
|
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
|
|
|
/**
|
|
* Core changelog generation logic
|
|
* Handles AI generation via Claude CLI subprocess
|
|
*/
|
|
export class ChangelogGenerator extends EventEmitter {
|
|
private generationProcesses: Map<string, ReturnType<typeof spawn>> = new Map();
|
|
private debugEnabled: boolean;
|
|
|
|
constructor(
|
|
private pythonPath: string,
|
|
private claudePath: string,
|
|
private autoBuildSourcePath: string,
|
|
private autoBuildEnv: Record<string, string>,
|
|
debugEnabled: boolean
|
|
) {
|
|
super();
|
|
this.debugEnabled = debugEnabled;
|
|
}
|
|
|
|
private debug(...args: unknown[]): void {
|
|
if (this.debugEnabled) {
|
|
console.warn('[ChangelogGenerator]', ...args);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate changelog using Claude AI
|
|
* Supports multiple source modes: tasks (specs), git-history, or branch-diff
|
|
*/
|
|
async generate(
|
|
projectId: string,
|
|
projectPath: string,
|
|
request: ChangelogGenerationRequest,
|
|
specs?: TaskSpecContent[]
|
|
): Promise<void> {
|
|
const sourceMode = request.sourceMode || 'tasks';
|
|
|
|
this.debug('generate called', {
|
|
projectId,
|
|
projectPath,
|
|
sourceMode,
|
|
taskCount: request.taskIds?.length || 0,
|
|
version: request.version,
|
|
format: request.format,
|
|
audience: request.audience
|
|
});
|
|
|
|
// Kill existing process if any
|
|
this.cancel(projectId);
|
|
|
|
let prompt: string;
|
|
let itemCount: number;
|
|
|
|
// Handle different source modes
|
|
if (sourceMode === 'git-history' && request.gitHistory) {
|
|
// Git history mode
|
|
this.emitProgress(projectId, {
|
|
stage: 'loading_commits',
|
|
progress: 10,
|
|
message: 'Loading commits from git history...'
|
|
});
|
|
|
|
const commits = getCommits(projectPath, request.gitHistory, this.debugEnabled);
|
|
if (commits.length === 0) {
|
|
this.emitError(projectId, 'No commits found for the specified range');
|
|
return;
|
|
}
|
|
|
|
prompt = buildGitPrompt(request, commits);
|
|
itemCount = commits.length;
|
|
|
|
} else if (sourceMode === 'branch-diff' && request.branchDiff) {
|
|
// Branch diff mode
|
|
this.emitProgress(projectId, {
|
|
stage: 'loading_commits',
|
|
progress: 10,
|
|
message: `Loading commits between ${request.branchDiff.baseBranch} and ${request.branchDiff.compareBranch}...`
|
|
});
|
|
|
|
const commits = getBranchDiffCommits(projectPath, request.branchDiff, this.debugEnabled);
|
|
if (commits.length === 0) {
|
|
this.emitError(projectId, 'No commits found between the specified branches');
|
|
return;
|
|
}
|
|
|
|
prompt = buildGitPrompt(request, commits);
|
|
itemCount = commits.length;
|
|
|
|
} else {
|
|
// Tasks mode (original behavior)
|
|
if (!specs || specs.length === 0) {
|
|
this.emitError(projectId, 'No specs provided for changelog generation');
|
|
return;
|
|
}
|
|
|
|
this.emitProgress(projectId, {
|
|
stage: 'loading_specs',
|
|
progress: 10,
|
|
message: 'Preparing changelog generation...'
|
|
});
|
|
|
|
prompt = buildChangelogPrompt(request, specs);
|
|
itemCount = specs.length;
|
|
}
|
|
|
|
this.debug('Prompt built', {
|
|
promptLength: prompt.length,
|
|
promptPreview: prompt.substring(0, 500) + '...'
|
|
});
|
|
|
|
// Create Python script
|
|
const script = createGenerationScript(prompt, this.claudePath);
|
|
this.debug('Python script created', { scriptLength: script.length });
|
|
|
|
this.emitProgress(projectId, {
|
|
stage: 'generating',
|
|
progress: 30,
|
|
message: 'Generating changelog with Claude AI...'
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
this.debug('Spawning Python process...');
|
|
|
|
// Build environment with explicit critical variables
|
|
const spawnEnv = this.buildSpawnEnvironment();
|
|
|
|
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
|
cwd: this.autoBuildSourcePath,
|
|
env: spawnEnv
|
|
});
|
|
|
|
this.generationProcesses.set(projectId, childProcess);
|
|
this.debug('Process spawned with PID:', childProcess.pid);
|
|
|
|
let output = '';
|
|
let errorOutput = '';
|
|
|
|
childProcess.stdout?.on('data', (data: Buffer) => {
|
|
const chunk = data.toString();
|
|
output += chunk;
|
|
this.debug('stdout chunk received', { chunkLength: chunk.length, totalOutput: output.length });
|
|
|
|
this.emitProgress(projectId, {
|
|
stage: 'generating',
|
|
progress: 50,
|
|
message: 'Generating changelog content...'
|
|
});
|
|
});
|
|
|
|
childProcess.stderr?.on('data', (data: Buffer) => {
|
|
const chunk = data.toString();
|
|
errorOutput += chunk;
|
|
this.debug('stderr chunk received', { chunk: chunk.substring(0, 200) });
|
|
});
|
|
|
|
childProcess.on('exit', (code: number | null) => {
|
|
const duration = Date.now() - startTime;
|
|
this.debug('Process exited', {
|
|
code,
|
|
duration: `${duration}ms`,
|
|
outputLength: output.length,
|
|
errorLength: errorOutput.length
|
|
});
|
|
|
|
this.generationProcesses.delete(projectId);
|
|
|
|
if (code === 0 && output.trim()) {
|
|
this.emitProgress(projectId, {
|
|
stage: 'formatting',
|
|
progress: 90,
|
|
message: 'Formatting changelog...'
|
|
});
|
|
|
|
// Extract changelog from output
|
|
const changelog = extractChangelog(output.trim());
|
|
this.debug('Changelog extracted', { changelogLength: changelog.length });
|
|
|
|
this.emitProgress(projectId, {
|
|
stage: 'complete',
|
|
progress: 100,
|
|
message: 'Changelog generation complete'
|
|
});
|
|
|
|
const result: ChangelogGenerationResult = {
|
|
success: true,
|
|
changelog,
|
|
version: request.version,
|
|
tasksIncluded: itemCount
|
|
};
|
|
|
|
this.debug('Generation complete, emitting result');
|
|
this.emit('generation-complete', projectId, result);
|
|
} else {
|
|
// Combine all output for error analysis
|
|
const combinedOutput = `${output}\n${errorOutput}`;
|
|
const error = errorOutput || `Generation failed with exit code ${code}`;
|
|
|
|
// Check for rate limit
|
|
const rateLimitDetection = detectRateLimit(combinedOutput);
|
|
if (rateLimitDetection.isRateLimited) {
|
|
this.debug('Rate limit detected in changelog generation', {
|
|
resetTime: rateLimitDetection.resetTime,
|
|
limitType: rateLimitDetection.limitType,
|
|
suggestedProfile: rateLimitDetection.suggestedProfile?.name
|
|
});
|
|
|
|
// Emit rate limit event
|
|
const rateLimitInfo = createSDKRateLimitInfo('changelog', rateLimitDetection, { projectId });
|
|
this.emit('rate-limit', projectId, rateLimitInfo);
|
|
}
|
|
|
|
this.debug('Generation failed', { error: error.substring(0, 500), isRateLimited: rateLimitDetection.isRateLimited });
|
|
this.emitError(projectId, error);
|
|
}
|
|
});
|
|
|
|
childProcess.on('error', (err: Error) => {
|
|
this.debug('Process error', { error: err.message });
|
|
this.generationProcesses.delete(projectId);
|
|
this.emitError(projectId, err.message);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build spawn environment with proper PATH and auth settings
|
|
*/
|
|
private buildSpawnEnvironment(): Record<string, string> {
|
|
const homeDir = os.homedir();
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// Build PATH with platform-appropriate separator and locations
|
|
const pathAdditions = isWindows
|
|
? [
|
|
path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude'),
|
|
path.join(homeDir, 'AppData', 'Roaming', 'npm'),
|
|
path.join(homeDir, '.local', 'bin'),
|
|
'C:\\Program Files\\Claude',
|
|
'C:\\Program Files (x86)\\Claude'
|
|
]
|
|
: [
|
|
'/usr/local/bin',
|
|
'/opt/homebrew/bin',
|
|
path.join(homeDir, '.local', 'bin'),
|
|
path.join(homeDir, 'bin')
|
|
];
|
|
|
|
// Get active Claude profile environment (OAuth token preferred, falls back to CLAUDE_CONFIG_DIR)
|
|
const profileEnv = getProfileEnv();
|
|
this.debug('Active profile environment', {
|
|
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
|
hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
|
authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default')
|
|
});
|
|
|
|
const spawnEnv: Record<string, string> = {
|
|
...process.env as Record<string, string>,
|
|
...this.autoBuildEnv,
|
|
...profileEnv, // Include active Claude profile config
|
|
// Ensure critical env vars are set for claude CLI
|
|
// Use USERPROFILE on Windows, HOME on Unix
|
|
...(isWindows ? { USERPROFILE: homeDir } : { HOME: homeDir }),
|
|
USER: process.env.USER || process.env.USERNAME || 'user',
|
|
// Add common binary locations to PATH for claude CLI
|
|
PATH: [process.env.PATH || '', ...pathAdditions].filter(Boolean).join(path.delimiter),
|
|
PYTHONUNBUFFERED: '1',
|
|
PYTHONIOENCODING: 'utf-8',
|
|
PYTHONUTF8: '1'
|
|
};
|
|
|
|
this.debug('Spawn environment', {
|
|
HOME: spawnEnv.HOME,
|
|
USER: spawnEnv.USER,
|
|
pathDirs: spawnEnv.PATH?.split(':').length,
|
|
authMethod: spawnEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (spawnEnv.CLAUDE_CONFIG_DIR ? `config-dir:${spawnEnv.CLAUDE_CONFIG_DIR}` : 'default')
|
|
});
|
|
|
|
return spawnEnv;
|
|
}
|
|
|
|
/**
|
|
* Cancel ongoing generation
|
|
*/
|
|
cancel(projectId: string): boolean {
|
|
const process = this.generationProcesses.get(projectId);
|
|
if (process) {
|
|
process.kill('SIGTERM');
|
|
this.generationProcesses.delete(projectId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Emit progress update
|
|
*/
|
|
private emitProgress(projectId: string, progress: ChangelogGenerationProgress): void {
|
|
this.emit('generation-progress', projectId, progress);
|
|
}
|
|
|
|
/**
|
|
* Emit error
|
|
*/
|
|
private emitError(projectId: string, error: string): void {
|
|
this.emit('generation-progress', projectId, {
|
|
stage: 'error',
|
|
progress: 0,
|
|
message: error,
|
|
error
|
|
});
|
|
this.emit('generation-error', projectId, error);
|
|
}
|
|
}
|