Files
Aperant/auto-claude-ui/src/main/changelog/generator.ts
T
2025-12-18 14:49:07 +01:00

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);
}
}