@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
2,425 lines โข 117 kB
JavaScript
import path from 'node:path';
import process from 'node:process';
import colors from '../../shared/constants/colors.js';
import { EnhancedConsole } from '../../shared/utils/cli-ui.js';
import { DiffProcessor } from '../../shared/utils/diff-processor.js';
import { assessFileImportance, categorizeFile, detectLanguage, getWorkingDirectoryChanges, handleUnifiedOutput, markdownCommitLink, markdownCommitRangeLink, markdownIssueLink, parseConventionalCommit, processIssueReferences, renderChangelogTemplate, sleep, summarizeFileChanges, } from '../../shared/utils/utils.js';
export class ChangelogService {
constructor(gitService, aiAnalysisService, analysisEngine = null, configManager = null) {
this.gitService = gitService;
this.aiAnalysisService = aiAnalysisService;
this.analysisEngine = analysisEngine;
this.configManager = configManager;
}
getRepositoryCwd() {
return this.gitService?.gitManager?.options?.cwd;
}
/**
* Filter out commits whose message matches the configured ignore pattern
* (defaults to WIP commits). Operates on `{hash, message}` objects returned
* by the git service.
* @param {Array<{hash: string, message: string}>} commits - Raw commit list
* @returns {Array<{hash: string, message: string}>} Commits to keep
*/
filterIgnoredCommits(commits) {
if (!(commits && Array.isArray(commits)) || commits.length === 0) {
return commits || [];
}
const ignoreRegex = this.configManager?.getCommitIgnoreRegex?.();
if (!ignoreRegex) {
return commits;
}
return commits.filter((commit) => {
const message = commit?.message || commit?.subject || '';
return !ignoreRegex.test(message);
});
}
classifyCommit(commitAnalysis, aiSummary = null) {
const fallbackType = commitAnalysis?.type || commitAnalysis?.semanticAnalysis?.category || 'other';
const fallbackBreaking = Boolean(commitAnalysis?.breaking ||
commitAnalysis?.semanticAnalysis?.breaking ||
(commitAnalysis?.breakingChanges?.length ?? 0) > 0);
if (aiSummary) {
return {
type: aiSummary.category || fallbackType,
breaking: Boolean(aiSummary.breaking ?? fallbackBreaking),
};
}
return {
type: fallbackType,
breaking: fallbackBreaking,
};
}
async enrichCommitWithAISummary(commitAnalysis, options = {}) {
const classification = this.classifyCommit(commitAnalysis);
// Keep commit data with rule-based classification when AI is unavailable OR explicitly
// disabled (includeAIAnalysis:false), so a --no-ai run still retains and classifies commits.
if (options.includeAIAnalysis === false ||
!this.aiAnalysisService?.generateAISummary ||
!this.aiAnalysisService?.selectOptimalModel) {
return {
...commitAnalysis,
...classification,
};
}
try {
const selectedModel = await this.aiAnalysisService.selectOptimalModel(commitAnalysis);
const aiSummary = await this.aiAnalysisService.generateAISummary(commitAnalysis, selectedModel);
const enrichedClassification = this.classifyCommit(commitAnalysis, aiSummary || null);
return {
...commitAnalysis,
...(aiSummary ? { aiSummary } : {}),
...enrichedClassification,
};
}
catch {
// Preserve commit analysis even when AI summarization fails.
return {
...commitAnalysis,
...classification,
};
}
}
async enrichCommitAnalysesWithAI(commitAnalyses, concurrency = 3, options = {}) {
const enriched = [];
for (let i = 0; i < commitAnalyses.length; i += concurrency) {
const chunk = commitAnalyses.slice(i, i + concurrency);
const chunkResults = await Promise.all(chunk.map((commit) => this.enrichCommitWithAISummary(commit, options)));
enriched.push(...chunkResults.filter(Boolean));
}
return enriched;
}
/**
* Generates a changelog from committed changes (git history)
* @param {string} version - Version number for the release
* @param {string} since - Generate changelog since this date/commit
* @param {Object} options - Additional options (dryRun, etc.)
* @returns {Promise<string>} Complete changelog content
*/
async generateChangelog(version = null, since = null, options = {}) {
const includeWorkingDirectoryChanges = options.includeWorkingDirectoryChanges === true;
const silent = options.silent === true || process.env.MCP_SERVER_MODE === 'true';
if (!silent) {
console.log(colors.processingMessage('๐ค Analyzing changes with AI...'));
}
// Get committed changes with optional filters
const rawCommits = await this.gitService.getCommitsSince(since, {
author: options.author,
until: options.until,
});
// Drop commits that match the configured ignore pattern (e.g. WIP commits)
// before any analysis happens so they never reach the changelog.
const commits = this.filterIgnoredCommits(rawCommits);
let workingDirAnalysis = null;
if (includeWorkingDirectoryChanges && this.analysisEngine) {
workingDirAnalysis = await this.analysisEngine.analyzeCurrentChanges();
}
if (commits.length === 0 && (!workingDirAnalysis || workingDirAnalysis.changes.length === 0)) {
if (!silent) {
console.log(colors.infoMessage('No commits or working directory changes found.'));
}
return;
}
const workingChangesCount = workingDirAnalysis ? workingDirAnalysis.changes.length : 0;
if (!silent) {
console.log(colors.processingMessage(`Found ${colors.number(commits.length)} commits and ${colors.number(workingChangesCount)} working directory changes...`));
}
// Extract hash strings from commit objects
const commitHashes = commits.map((c) => c.hash);
if (!silent) {
console.log(colors.processingMessage(`Analyzing ${colors.number(commitHashes.length)} commits...`));
}
// Use batch processing for large commit sets
let analyzedCommits;
if (commitHashes.length > 20) {
if (!silent) {
console.log(colors.infoMessage('Using batch processing for large commit set...'));
}
analyzedCommits = await this.generateChangelogBatch(commitHashes, options);
}
else {
analyzedCommits = await this.processCommitsSequentially(commitHashes, options);
}
if (analyzedCommits.length === 0) {
if (!silent) {
console.log(colors.warningMessage('No valid commits to analyze.'));
}
return;
}
// Generate release insights including working directory changes
const insights = await this.generateReleaseInsights(analyzedCommits, version, workingDirAnalysis);
// Build final changelog including both committed and working changes.
// `options` is threaded so template selection and attribution are honored.
const changelog = await this.buildChangelog(analyzedCommits, insights, version, workingDirAnalysis, options);
// Route through the unified writer when an output file or a non-default
// format is requested so --format/--output/--dry-run behave consistently.
if (options.outputFile || (options.format && options.format !== 'markdown')) {
handleUnifiedOutput(changelog, {
format: options.format || 'markdown',
outputFile: options.outputFile,
silent,
dryRun: options.dryRun,
version,
});
}
return {
changelog,
insights,
analyzedCommits,
workingDirAnalysis,
};
}
/**
* Progress chatter must stay off when the caller asked for silence (library
* embedding, VS Code output channel) or when running as an MCP server.
*/
isSilent(options = {}) {
return options.silent === true || process.env.MCP_SERVER_MODE === 'true';
}
async processCommitsSequentially(commitHashes, options = {}) {
// Optimize by using concurrent processing with controlled concurrency
const concurrency = 3; // Limit concurrent API calls to avoid rate limits
const analyzedCommits = [];
// Honor an explicit AI opt-out (--no-ai / includeAIAnalysis:false): skip the per-commit
// AI round-trip and rely on rule-based classifyCommit, while still retaining the commit.
const useAI = options.includeAIAnalysis !== false;
const silent = this.isSilent(options);
if (!silent) {
console.log(colors.processingMessage(`Processing ${commitHashes.length} commits with ${concurrency} concurrent operations...`));
}
// Process in chunks to control concurrency
for (let i = 0; i < commitHashes.length; i += concurrency) {
const chunk = commitHashes.slice(i, i + concurrency);
// Process chunk concurrently
const chunkPromises = chunk.map(async (commitHash, index) => {
const globalIndex = i + index;
// Reading the commit from git is the only step that can legitimately drop
// it. AI enrichment is optional, so its failure must never remove a commit
// that git read successfully โ otherwise a provider outage silently
// empties a small release while a large one (which goes through the batch
// path) survives the same outage.
let commitAnalysis;
try {
commitAnalysis = await this.gitService.getCommitAnalysis(commitHash);
}
catch (error) {
console.warn(colors.warningMessage(`Failed to read commit ${commitHash.substring(0, 7)}: ${error.message}`));
return null;
}
if (!commitAnalysis) {
return null;
}
if (!silent) {
console.log(colors.processingMessage(`Processing commit ${colors.highlight(`${globalIndex + 1}/${commitHashes.length}`)}: ${colors.hash(commitHash.substring(0, 7))}`));
}
if (!useAI) {
return {
...commitAnalysis,
...this.classifyCommit(commitAnalysis),
};
}
try {
const aiSummary = await this.aiAnalysisService.generateAISummary(commitAnalysis, await this.aiAnalysisService.selectOptimalModel(commitAnalysis));
return {
...commitAnalysis,
...(aiSummary ? { aiSummary } : {}),
...this.classifyCommit(commitAnalysis, aiSummary || null),
};
}
catch (error) {
console.warn(colors.warningMessage(`AI summary unavailable for ${commitHash.substring(0, 7)} (${error.message}); using rule-based analysis.`));
return {
...commitAnalysis,
...this.classifyCommit(commitAnalysis),
};
}
});
// Wait for chunk to complete and add successful results
const chunkResults = await Promise.all(chunkPromises);
analyzedCommits.push(...chunkResults.filter((result) => result !== null));
// Brief delay between chunks to be respectful to APIs
if (i + concurrency < commitHashes.length) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return analyzedCommits;
}
async generateChangelogBatch(commitHashes, options = {}) {
const batchSize = 10;
const results = [];
const silent = this.isSilent(options);
for (let i = 0; i < commitHashes.length; i += batchSize) {
const batch = commitHashes.slice(i, i + batchSize);
const batchNum = Math.floor(i / batchSize) + 1;
const totalBatches = Math.ceil(commitHashes.length / batchSize);
if (!silent) {
console.log(colors.processingMessage(`Processing batch ${colors.highlight(`${batchNum}/${totalBatches}`)} (${colors.number(batch.length)} commits)`));
console.log(colors.progress(batchNum, totalBatches, 'batches processed'));
}
const batchPromises = batch.map((hash) => this.gitService.getCommitAnalysis(hash));
const batchResults = await Promise.allSettled(batchPromises);
const successfulResults = batchResults.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : []);
const enrichedResults = await this.enrichCommitAnalysesWithAI(successfulResults, 3, options);
results.push(...enrichedResults);
// Rate limiting between batches
if (i + batchSize < commitHashes.length) {
await sleep(1000);
}
}
return results.filter(Boolean);
}
async generateReleaseInsights(analyzedCommits, version, workingDirAnalysis = null) {
const insights = {
summary: '',
totalCommits: analyzedCommits.length,
commitTypes: {},
riskLevel: 'low',
affectedAreas: [],
breaking: false,
complexity: 'low',
businessImpact: 'minor',
deploymentRequirements: [],
};
// Count commit types and assess risk
analyzedCommits.forEach((commit) => {
insights.commitTypes[commit.type] = (insights.commitTypes[commit.type] || 0) + 1;
if (commit.breaking) {
insights.breaking = true;
}
if (commit.semanticAnalysis?.riskLevel === 'high') {
insights.riskLevel = 'high';
}
commit.files.forEach((file) => {
if (typeof file.category === 'string' && file.category.length > 0) {
insights.affectedAreas.push(file.category);
}
});
// Check for deployment requirements
if (commit.semanticAnalysis?.hasDbChanges) {
insights.deploymentRequirements.push('Database migration required');
}
if (commit.breaking) {
insights.deploymentRequirements.push('Breaking changes - review migration notes above.');
}
});
insights.affectedAreas = Array.from(new Set(insights.affectedAreas));
// Include working directory changes in insights
if (workingDirAnalysis && workingDirAnalysis.changes.length > 0) {
insights.workingDirectoryChanges = {
count: workingDirAnalysis.changes.length,
summary: workingDirAnalysis.analysis
? (workingDirAnalysis.analysis.summary ?? 'Working directory changes detected')
: 'Working directory changes detected',
};
// Add working directory files to affected areas
workingDirAnalysis.changes.forEach((change) => {
if (typeof change.category === 'string' && change.category.length > 0) {
insights.affectedAreas.push(change.category);
}
});
// Remove duplicates
insights.affectedAreas = Array.from(new Set(insights.affectedAreas));
}
// Assess overall complexity
const totalFiles = analyzedCommits.reduce((sum, commit) => sum + commit.files.length, 0);
const avgFilesPerCommit = totalFiles / analyzedCommits.length;
if (avgFilesPerCommit > 20 || insights.breaking) {
insights.complexity = 'high';
}
else if (avgFilesPerCommit > 10) {
insights.complexity = 'medium';
}
// Generate summary based on insights
insights.summary = this.generateInsightsSummary(insights, version);
return insights;
}
generateInsightsSummary(insights, version) {
const { totalCommits, commitTypes, breaking, complexity, affectedAreas } = insights;
let summary = `Release ${version || 'latest'} includes ${totalCommits} commits`;
if (breaking) {
summary += ' with breaking changes';
}
if (Object.keys(commitTypes).length > 0) {
const types = Object.entries(commitTypes)
.sort(([, a], [, b]) => Number(b) - Number(a))
.map(([type, count]) => `${count} ${type}`)
.join(', ');
summary += ` (${types})`;
}
summary += `. Complexity: ${complexity}. Affected areas: ${affectedAreas.join(', ')}.`;
return summary;
}
async buildChangelog(analyzedCommits, _insights, version, workingDirAnalysis = null, options = {}) {
const timestamp = new Date().toISOString().split('T')[0];
const commitUrl = this.configManager?.getCommitUrl();
const commitRangeUrl = this.configManager?.getCommitRangeUrl();
const issueUrl = this.configManager?.getIssueUrl();
const issueRegex = this.configManager?.getIssueRegexPattern();
const includeAttribution = options.includeAttribution ?? this.configManager?.getIncludeAttribution?.() ?? true;
// Group commits by conventional type up front so both the templated and the
// default layouts share the same grouping/breaking-change computation.
const groupedCommits = this.groupCommitsByType(analyzedCommits);
const breakingCommits = analyzedCommits.filter((commit) => commit.breaking || (commit.breakingChanges && commit.breakingChanges.length > 0));
// Optional template rendering: when a template is requested, build a
// TemplateData object from the grouped changes and delegate to the shared
// template engine instead of emitting the hardcoded layout below.
if (options.template) {
const templateData = this.buildTemplateData(groupedCommits, breakingCommits, version, timestamp, { commitUrl, issueUrl, issueRegex });
let templated = renderChangelogTemplate(options.template, templateData);
// Working directory changes are not part of the structured commit data, so
// append them after the templated body to preserve the feature.
if (workingDirAnalysis?.changes && workingDirAnalysis.changes.length > 0) {
templated += `\n${await this.buildWorkingDirectorySection(workingDirAnalysis)}`;
}
return templated;
}
// Keep a Changelog standard header
let changelog = '# Changelog\n\n';
changelog += 'All notable changes to this project will be documented in this file.\n\n';
changelog +=
'The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\n';
changelog +=
'and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n';
// Version header with commit range link
const firstCommit = analyzedCommits[0];
const lastCommit = analyzedCommits.at(-1);
let versionHeader = `## [${version || 'Unreleased'}] - ${timestamp}`;
if (firstCommit && lastCommit && commitRangeUrl) {
const rangeLink = markdownCommitRangeLink(lastCommit.fullHash, firstCommit.fullHash, commitRangeUrl);
versionHeader += ` (${rangeLink})`;
}
changelog += `${versionHeader}\n\n`;
// Define preferred order for changelog sections
const preferredOrder = [
'feat',
'fix',
'perf',
'refactor',
'docs',
'test',
'build',
'ci',
'chore',
'style',
'revert',
'other',
];
const sortedTypes = preferredOrder
.filter((type) => groupedCommits[type] && groupedCommits[type].length > 0)
.concat(Object.keys(groupedCommits).filter((type) => !preferredOrder.includes(type) && groupedCommits[type].length > 0));
// Generate sections for each commit type
for (const type of sortedTypes) {
const commits = groupedCommits[type];
if (commits.length === 0) {
continue;
}
changelog += `### ${this.getTypeHeader(type)}\n\n`;
for (const commit of commits) {
changelog += this.buildCommitEntry(commit, commitUrl, issueUrl, issueRegex);
}
changelog += '\n';
}
// Breaking changes section (if any breaking changes exist)
if (breakingCommits.length > 0) {
changelog += '### ๐จ BREAKING CHANGES\n\n';
for (const commit of breakingCommits) {
changelog += this.buildBreakingChangeEntry(commit, commitUrl, issueUrl, issueRegex);
}
changelog += '\n';
}
// Append working directory changes if present
if (workingDirAnalysis?.changes && workingDirAnalysis.changes.length > 0) {
const workingDirSection = await this.buildWorkingDirectorySection(workingDirAnalysis);
changelog += workingDirSection;
}
// Attribution footer (suppressed when attribution is disabled)
if (includeAttribution) {
changelog += '---\n\n';
changelog +=
'*Generated using [ai-changelog-generator](https://github.com/entro314-labs/AI-changelog-generator) - AI-powered changelog generation for Git repositories*\n';
}
return changelog;
}
/**
* Build a structured {@link TemplateData} object from grouped commits and
* breaking changes for rendering via the shared template engine.
* @param {Record<string, any[]>} groupedCommits - Commits grouped by type
* @param {any[]} breakingCommits - Commits flagged as breaking
* @param {string|null} version - Release version
* @param {string} timestamp - ISO date (YYYY-MM-DD)
* @param {Object} linkConfig - { commitUrl, issueUrl, issueRegex, includeAttribution }
* @returns {TemplateData} Structured changelog data
*/
buildTemplateData(groupedCommits, breakingCommits, version, timestamp, linkConfig = {}) {
const { commitUrl, issueUrl, issueRegex } = linkConfig;
const changes = {};
for (const [type, commits] of Object.entries(groupedCommits)) {
if (!(commits && commits.length > 0)) {
continue;
}
changes[type] = commits.map((commit) => {
let description = commit.aiSummary?.summary || commit.description || commit.subject || 'No description';
description = description.replace(/^(feat|fix|refactor|docs|chore|test|style|perf|build|ci):\s*/i, '');
if (commit.scope) {
description = `**${commit.scope}**: ${description}`;
}
if (issueUrl && issueRegex) {
description = processIssueReferences(description, issueUrl, issueRegex);
}
const hash = commit.fullHash || commit.hash;
const entry = { description };
if (hash) {
entry.hash = hash;
// The template engine appends the hash itself; it needs the raw target
// URL (built from the configured %commit% template), not a markdown link.
if (commitUrl) {
entry.commitUrl = commitUrl.replace('%commit%', hash);
}
}
if (commit.author || commit.authorName) {
entry.author = commit.author || commit.authorName;
}
const details = commit.aiSummary?.technicalDetails;
if (details && details !== description) {
entry.details = details;
}
return entry;
});
}
const breaking = breakingCommits.map((commit) => {
let description = '';
if (commit.aiSummary?.breakingChangeDescription) {
description = commit.aiSummary.breakingChangeDescription;
}
else if (commit.breakingChanges && commit.breakingChanges.length > 0) {
description = commit.breakingChanges[0];
}
else {
description =
commit.aiSummary?.summary || commit.description || commit.subject || 'Breaking change';
}
description = description.replace(/^(feat|fix|refactor|docs|chore|test|style|perf|build|ci):\s*/i, '');
const entry = { description };
if (commit.aiSummary?.migrationNotes) {
entry.migration = commit.aiSummary.migrationNotes;
}
return entry;
});
const totalCommits = Object.values(changes).reduce((sum, entries) => sum + entries.length, 0);
return {
title: 'Changelog',
version: version || 'Unreleased',
date: timestamp,
changes,
breaking,
metadata: {
totalCommits,
includeCommitHash: Boolean(commitUrl),
includeAuthor: false,
},
aiProvider: this.aiAnalysisService?.aiProvider?.name,
};
}
buildCommitEntry(commit, commitUrl, issueUrl, issueRegex) {
// Use AI-generated summary if available, fallback to commit message
let title = commit.aiSummary?.summary || commit.description || commit.subject || 'No description';
// Clean up title - remove conventional commit prefix if it exists
title = title.replace(/^(feat|fix|refactor|docs|chore|test|style|perf|build|ci):\s*/i, '');
// Process issue references in title
if (issueUrl && issueRegex) {
title = processIssueReferences(title, issueUrl, issueRegex);
}
// Build clean entry
let entry = '- ';
// Add scope if available
if (commit.scope) {
entry += `**${commit.scope}**: `;
}
// Add the main title/description
entry += title;
// Add commit link if available
if (commitUrl && (commit.fullHash || commit.hash)) {
const commitLink = markdownCommitLink(commit.fullHash || commit.hash, commitUrl);
entry += ` (${commitLink})`;
}
// Add additional technical details if available and different from title
const techDetails = commit.aiSummary?.technicalDetails;
if (techDetails && techDetails !== title && techDetails.length > title.length) {
entry += `\n - ${techDetails}`;
}
// Add migration notes if available
if (commit.aiSummary?.migrationNotes) {
entry += `\n - **Migration**: ${commit.aiSummary.migrationNotes}`;
}
// Add issue references from commit body
if (commit.issueReferences && commit.issueReferences.length > 0) {
const issueLinks = commit.issueReferences
.map((ref) => (issueUrl ? markdownIssueLink(ref, issueUrl) : ref))
.join(', ');
entry += `\n - References: ${issueLinks}`;
}
// Add closes references
if (commit.closesReferences && commit.closesReferences.length > 0) {
const closesLinks = commit.closesReferences
.map((ref) => (issueUrl ? markdownIssueLink(ref, issueUrl) : ref))
.join(', ');
entry += `\n - Closes: ${closesLinks}`;
}
return `${entry}\n`;
}
buildBreakingChangeEntry(commit, commitUrl, _issueUrl, _issueRegex) {
let entry = '- ';
// Add scope if available
if (commit.scope) {
entry += `**${commit.scope}**: `;
}
// Use AI-generated breaking change description if available
let breakingDescription = '';
if (commit.aiSummary?.breakingChangeDescription) {
breakingDescription = commit.aiSummary.breakingChangeDescription;
}
else if (commit.breakingChanges && commit.breakingChanges.length > 0) {
breakingDescription = commit.breakingChanges[0];
}
else {
breakingDescription =
commit.aiSummary?.summary || commit.description || commit.subject || 'Breaking change';
}
// Clean up breaking description
breakingDescription = breakingDescription.replace(/^(feat|fix|refactor|docs|chore|test|style|perf|build|ci):\s*/i, '');
entry += breakingDescription;
// Add commit link if available
if (commitUrl && (commit.fullHash || commit.hash)) {
const commitLink = markdownCommitLink(commit.fullHash || commit.hash, commitUrl);
entry += ` (${commitLink})`;
}
// Add migration notes for breaking changes
if (commit.aiSummary?.migrationNotes) {
entry += `\n - **Migration**: ${commit.aiSummary.migrationNotes}`;
}
// Add additional breaking change details
if (commit.breakingChanges && commit.breakingChanges.length > 1) {
commit.breakingChanges.slice(1).forEach((change) => {
entry += `\n - ${change}`;
});
}
// Add technical details for breaking changes if different from description
const techDetails = commit.aiSummary?.technicalDetails;
if (techDetails &&
techDetails !== breakingDescription &&
techDetails.length > breakingDescription.length) {
entry += `\n - ${techDetails}`;
}
return `${entry}\n`;
}
groupCommitsByType(commits) {
const configuredTypes = this.configManager?.getChangelogCommitTypes() || [
'feat',
'fix',
'refactor',
'perf',
'docs',
'build',
'chore',
];
const includeInvalid = this.configManager?.shouldIncludeInvalidCommits() ?? true;
const types = {};
commits.forEach((commit) => {
// ALWAYS parse conventional commit first - this is the primary source of truth for types
const conventionalCommit = parseConventionalCommit(commit.subject || commit.message, commit.body);
let type = conventionalCommit.type;
// Only use AI analysis type as fallback if no conventional commit type found
if (!type || type === 'other') {
type = commit.aiSummary?.category || commit.type || (includeInvalid ? 'other' : null);
}
// Only include configured types or invalid commits if allowed
if (type && (configuredTypes.includes(type) || (type === 'other' && includeInvalid))) {
if (!types[type]) {
types[type] = [];
}
// Preserve AI analysis and enhance with conventional commit data
types[type].push({
...commit, // Preserves aiSummary and other AI analysis
...conventionalCommit, // Add conventional commit fields - this includes scope, breaking, etc.
type,
});
}
});
return types;
}
getTypeHeader(type) {
const configuredHeadlines = this.configManager?.getHeadlines() || {};
// Use configured headlines from YAML config or defaults
const defaultHeaders = {
feat: '๐ Features',
fix: '๐ Bug Fixes',
perf: 'โก Performance Improvements',
refactor: 'โป๏ธ Refactoring',
docs: '๐ Documentation',
test: '๐งช Tests',
build: '๐ง Build System',
ci: 'โ๏ธ CI/CD',
chore: '๐ง Maintenance',
style: '๐ Code Style',
revert: 'โช Reverts',
merge: '๐ Merges',
other: '๐ฆ Other Changes',
};
return (configuredHeadlines[type] ||
defaultHeaders[type] ||
`๐ฆ ${type.charAt(0).toUpperCase() + type.slice(1)}`);
}
/**
* Generates a changelog from working directory changes (uncommitted files)
* @param {string} version - Version number for the release
* @param {Object} options - Working-dir options: { analysisMode, includeAttribution, format, outputFile, dryRun, silent }
* @returns {Promise<string>} Changelog content for working directory changes
*/
async generateChangelogFromChanges(version = null, options = {}) {
const silent = options.silent === true || process.env.MCP_SERVER_MODE === 'true';
// Honor the caller's analysis mode, falling back to the configured default
// and finally "standard" instead of the previous hardcoded "detailed".
const analysisMode = options.analysisMode ?? this.configManager?.getDefaultAnalysisMode?.() ?? 'standard';
const includeAttribution = options.includeAttribution ?? this.configManager?.getIncludeAttribution?.() ?? true;
// Use the proper WorkspaceChangelogService with optimized single AI call approach
const result = await this.generateWorkspaceChangelog(version, {
analysisMode,
includeAttribution,
});
if (!result) {
if (!silent) {
console.log(colors.infoMessage('No changes detected in working directory.'));
}
return null;
}
// Route working-dir output through the unified writer so --format/--output/
// --dry-run behave the same as the core committed-changes path.
if (options.outputFile || (options.format && options.format !== 'markdown')) {
handleUnifiedOutput(result.changelog, {
format: options.format || 'markdown',
outputFile: options.outputFile,
silent,
dryRun: options.dryRun,
version,
});
}
return {
changelog: result.changelog,
analysis: result.summary,
changes: result.changes,
};
}
/**
* Builds the working directory changes section for the changelog
* @param {Object} workingDirAnalysis - Analysis results from analysis engine
* @returns {Promise<string>} Formatted working directory section
*/
async buildWorkingDirectorySection(workingDirAnalysis) {
// Use the optimized WorkspaceChangelogService for consistent formatting
const workspaceResult = await this.generateCommitStyleWorkingDirectoryEntries({
analysisMode: 'detailed',
workingDirAnalysis,
});
if (!workspaceResult?.entries || workspaceResult.entries.length === 0) {
return '';
}
let section = '### ๐ง Unreleased Changes\n\n';
// Add each entry in clean format
workspaceResult.entries.forEach((entry) => {
// Clean up the AI-generated entries to match our new format
let cleanEntry = entry.replace(/^- \([\w]+\)\s*/, '- '); // Remove type prefixes like "(feature)"
cleanEntry = cleanEntry.replace(/^- (.+?) - (.+)$/, '- $2'); // Extract just the description
section += `${cleanEntry}\n`;
});
section += '\n';
return section;
}
async generateAIPoweredChangeEntry(change) {
const path = change.path || change.filePath || 'unknown';
const status = change.status || '??';
const _category = change.category || 'other';
// Map status to change type
const statusTypeMap = {
'??': 'feature',
A: 'feature',
M: 'update',
D: 'remove',
R: 'refactor',
};
const changeType = statusTypeMap[status] || 'other';
// Use AI to analyze the actual diff content for intelligent description
let description = 'Working directory change';
let confidence = 85;
try {
// Use AI analysis for working directory files to get better descriptions
// especially important for architectural changes (deleted files)
if (this.aiAnalysisService?.hasAI) {
const aiSummary = await this.aiAnalysisService.generateAISummary({
subject: `Working directory change: ${path}`,
files: [change],
diffStats: { files: 1, insertions: 0, deletions: 0 },
});
if (aiSummary?.technicalDetails) {
description = aiSummary.technicalDetails;
confidence = 85;
}
else {
description = this.analyzeDiffContentForDescription(change);
confidence = 75;
}
}
else {
description = this.analyzeDiffContentForDescription(change);
confidence = 75;
}
}
catch {
console.warn(`AI analysis failed for ${path}, using enhanced fallback`);
// Use enhanced analysis instead of generic
description = this.analyzeDiffContentForDescription(change);
confidence = 75;
}
// Format like commit entry
return `- (${changeType}) ${path} - ${description} (${confidence}%)`;
}
generateWorkingDirectoryChangeEntry(change) {
const path = change.path || change.filePath || 'unknown';
const status = change.status || '??';
const _category = change.category || 'other';
const _importance = change.importance || 'medium';
// Map status to change type
const statusTypeMap = {
'??': 'feature',
A: 'feature',
M: 'update',
D: 'remove',
R: 'refactor',
};
const changeType = statusTypeMap[status] || 'other';
// Generate AI-style description
const description = this.generateChangeDescription(change);
// Calculate confidence score based on change analysis
const confidence = this.calculateChangeConfidence(change);
// Format like commit entry
return `- (${changeType}) ${path} - ${description} (${confidence}%)`;
}
generateChangeDescription(change) {
const path = change.path || change.filePath;
const status = change.status;
const diff = change.diff || '';
const category = change.category || 'other';
if (status === 'D') {
return `Removed ${category} file from working directory`;
}
if (status === 'A' || status === '??') {
// Handle directories differently from files
if (path.endsWith('/')) {
return this.analyzeDirectoryAddition(path);
}
// For new files, analyze the actual content from the diff
const contentAnalysis = this.analyzeNewFileContent(diff, path);
if (contentAnalysis) {
return contentAnalysis;
}
return `Added new ${category} file to working directory`;
}
if (status === 'M') {
// Analyze what actually changed in the diff - prioritize functional analysis
const changeAnalysis = this.analyzeModifiedFileChanges(diff, path);
if (changeAnalysis) {
return `Modified ${category} file - ${changeAnalysis}`;
}
// Fallback to generic line count only if no functional analysis available
const lines = diff.split('\n');
const additions = lines.filter((line) => line.startsWith('+') && !line.startsWith('+++')).length;
const deletions = lines.filter((line) => line.startsWith('-') && !line.startsWith('---')).length;
return `Modified ${category} file with ${additions} additions and ${deletions} deletions`;
}
if (status === 'R') {
return `Renamed ${category} file in working directory`;
}
return `Updated ${category} file in working directory`;
}
analyzeNewFileContent(diff, path) {
if (!diff?.includes('Content preview:')) {
return null;
}
// Extract the content preview from our diff
const previewMatch = diff.match(/Content preview:\n([\s\S]*?)(?:\n\.\.\. \(truncated\)|$)/);
if (!previewMatch?.[1]) {
return null;
}
const content = previewMatch[1];
const filename = path ? path.split('/').pop() : 'file';
// Analyze based on file type and content
if (path?.endsWith('.md')) {
return this.analyzeMarkdownContent(content, filename);
}
if (path && (path.endsWith('.js') || path.endsWith('.ts'))) {
return this.analyzeJavaScriptContent(content, filename);
}
if (path?.endsWith('.json')) {
return this.analyzeJsonContent(content, filename);
}
if (path?.startsWith('.')) {
return this.analyzeConfigContent(content, filename);
}
// Generic content analysis
const lines = content.split('\n').length;
return `Added new file containing ${lines} lines of content`;
}
analyzeMarkdownContent(content, filename) {
const lines = content.split('\n');
const headings = lines.filter((line) => line.startsWith('#')).length;
const codeBlocks = (content.match(/```/g) || []).length / 2;
const links = (content.match(/\[.*?\]\(.*?\)/g) || []).length;
const tables = (content.match(/\|.*\|/g) || []).length;
// Extract actual content insights
const contentLower = content.toLowerCase();
let documentType = '';
const keyContent = [];
if (filename.includes('MAPPING') || filename.includes('mapping')) {
documentType = 'architectural mapping documentation';
if (contentLower.includes('class')) {
keyContent.push('class documentation');
}
if (contentLower.includes('function')) {
keyContent.push('function mapping');
}
if (contentLower.includes('service')) {
keyContent.push('service architecture');
}
}
else if (filename.includes('ENVIRONMENT') || filename.includes('env')) {
documentType = 'environment variable configuration guide';
const envVars = (content.match(/[A-Z_]+=/g) || []).length;
if (envVars > 0) {
keyContent.push(`${envVars} environment variables`);
}
}
else if (filename.includes('README') || filename.includes('readme')) {
documentType = 'project documentation';
if (contentLower.includes('install')) {
keyContent.push('installation guide');
}
if (contentLower.includes('setup')) {
keyContent.push('setup instructions');
}
}
else if (filename.includes('CHANGELOG') || filename.includes('changelog')) {
documentType = 'changelog documentation';
const versions = (content.match(/##?\s+\[?\d+\.\d+/g) || []).length;
if (versions > 0) {
keyContent.push(`${versions} version entries`);
}
}
else {
documentType = 'documentation';
}
let analysis = `Added ${filename} ${documentType}`;
// Add specific content details
const details = [];
if (keyContent.length > 0) {
details.push(...keyContent);
}
if (headings > 0) {
details.push(`${headings} sections`);
}
if (codeBlocks > 0) {
details.push(`${codeBlocks} code examples`);
}
if (tables > 0) {
details.push(`${tables} table rows`);
}
if (links > 0) {
details.push(`${links} links`);
}
if (details.length > 0) {
analysis += ` containing ${details.slice(0, 3).join(', ')}`;
}
return analysis;
}
analyzeJavaScriptContent(content, filename) {
const functions = (content.match(/function\s+\w+|const\s+\w+\s*=\s*\(|=>\s*{/g) || []).length;
const imports = (content.match(/import\s+.*?from|require\s*\(/g) || []).length;
const classes = (content.match(/class\s+\w+/g) || []).length;
const exports = (content.match(/export\s+/g) || []).length;
// Extract actual content insights
const lines = content.split('\n');
const _comments = lines.filter((line) => line.trim().startsWith('//') || line.trim().startsWith('*')).length;
const consoleStatements = (content.match(/console\.(log|error|warn|info)/g) || []).length;
// Look for specific patterns that indicate purpose
let purpose = '';
if (content.includes('verification') || content.includes('check')) {
purpose = 'verification script';
}
else if (content.includes('test') || content.includes('spec')) {
purpose = 'test file';
}
else if (content.includes('server') || content.includes('mcp')) {
purpose = 'MCP server entry point';
}
else if (content.includes('setup') || content.includes('config')) {
purpose = 'setup/configuration script';
}
else if (content.includes('import') && content.includes('update')) {
purpose = 'import update utility';
}
else if (filename.includes('util')) {
purpose = 'utility module';
}
else {
purpose = 'JavaScript module';
}
// Extract meaningful text content for analysis
const meaningfulContent = content
.split('\n')
.filter((line) => line.trim() && !line.trim().startsWith('//') && !line.trim().startsWith('*'))
.join(' ')
.toLowerCase();
// Look for specific functionality indicators
const functionality = [];
if (meaningfulContent.includes('method') && meaningfulContent.includes('analysis')) {
functionality.push('analyzing methods');
}
if (meaningfulContent.includes('legacy') || meaningfulContent.includes('old')) {
functionality.push('legacy code handling');
}
if (meaningfulContent.includes('repository') || meaningfulContent.includes('repo')) {
functionality.push('repository analysis');
}
if (meaningfulContent.includes('investigation') || meaningfulContent.includes('check')) {
functionality.push('investigation checklist');
}
if (consoleStatements > 3) {
functionality.push('detailed logging');
}
let analysis = `Added ${filename.replace('.js', '')} ${purpose}`;
// Add functionality description
if (functionality.length > 0) {
analysis += ` for ${functionality.slice(0, 2).join(' and ')}`;
}
const features = [];
if (classes > 0) {
features.push(`${classes} class${classes > 1 ? 'es' : ''}`);
}
if (functions > 0) {
features.push(`${functions} function${functions > 1 ? 's' : ''}`);
}
if (imports > 0) {
features.push(`${imports} import${imports > 1 ? 's' : ''}`);
}
if (exports > 0) {
features.push(`${exports} export${exports > 1 ? 's' : ''}`);
}
if (features.length > 0) {
analysis += ` with ${features.slice(0, 3).join(', ')}`;
}
return analysis;
}
analyzeJsonContent(content, filename) {
try {
const parsed = JSON.parse(content);
if (filename === 'package.json') {
const deps = Object.keys(parsed.dependencies || {}).length;
const devDeps = Object.keys(parsed.devDependencies || {}).length;
const scripts = Object.keys(parsed.scripts || {}).length;
return `Added package.json with ${deps} dependencies, ${devDeps} dev dependencies, and ${scripts} scripts`;
}
if (filename === 'manifest.json') {
return `Added manifest.json configuration for ${parsed.name || 'application'} with ${Object.keys(parsed).length} properties`;
}
const keys = Object.keys(parsed).length;
return `Added ${filename} configuration with ${keys} settings`;
}
catch {
return `Added ${filename} JSON configuration file`;
}
}
analyzeConfigContent(content, filename) {
const lines = content.split('\n').filter((line) => line.trim()).length;
if (filename.includes('env')) {
const variables = content.split('\n').filter((line) => line.includes('=')).length;
return `Added environment configuration with ${variables} variables`;
}
if (filename.includes('ignore')) {
const patterns = content
.split('\n')
.filter((line) => line.trim() && !line.startsWith('#')).length;
return `Added ignore file with ${patterns} patterns`;
}
return `Added ${filename} configuration with ${lines} lines`;
}
analyzeDirectoryAddition(path) {
const dirName = path.replace(/\/$/, '');
// Analyze based on directory name and purpose
if (dirName === '.claude') {
return 'Added Claude Code configuration directory for AI coding assistant integration';
}
if (dirName === '.context') {
return 'Added context directory for project context and documentation';
}
if (dirName === '.github') {
return 'Added GitHub configuration directory for workflows, templates, and repository settings';
}
if (dirName === 'docs') {
return 'Added documentation directory for project documentation';
}
if (dirName === 'test' || dirName === 'tests') {
return 'Added test directory for unit tests and test files';
}
if (dirName === 'src') {
return 'Added source code directory for main application code';
}
if (dirName === 'lib') {
return 'Added library directory for shared code and utilities';
}
if (dirName === 'bin') {
return 'Added binary directory for executable scripts';
}
if (dirName === 'config') {
return 'Added configuration directory for application settings';
}
if (dirName === 'server') {
return 'Added server directory for server-side code and configuration';
}
if (dirName === 'dxt') {
return 'Added DXT directory for development tooling and utilities';
}
if (dirName.includes('test')) {
return `Added ${dirName} directory for testing and test results`;
}
if (dirName.startsWith('.')) {
return `Added ${dirName} configuration directory`;
}
return `Added ${dirName} directory`;
}
groupWorkingDirectoryChangesByType(changes) {
const grouped = {
architectural: [],
feature: [],
refactor: [],
update: [],
remove: [],
config: [],
docs: [],
test: [],
};
for (const change of changes) {
const path = change.path || change.filePath || '';
const status = change.status || '??';
const _category = change.category || 'other';
// Determine the change type based on content and context
if (this.isArchitecturalChange(path, change)) {
grouped.architectural.push(change);
}
else if (status === 'D') {
grouped.remove.push(change);
}
else if (status === 'M') {
if (this.isConfigurationFile(path)) {
grouped.config.push(change);
}
else {
grouped.update.push(change);
}
}
else if (status === 'A' || status === '??') {
if (this.isTestFile(path)) {
grouped.test.push(change);
}
else if (this.isDocumentationFile(path)) {
grouped.docs.push(change);
}
else if (this.isConfigurationFile(path)) {
grouped.config.push(change);
}
else {
grouped.feature.push(change);
}
}
}
// Remove empty groups
return Object.fromEntries(Object.entries(grouped).filter(([_key, value]) => value.length > 0));
}
async generateGroupedChangeEntry(changeType, files) {
const fileCount = files.length;
// Generate individual file descriptions using our existing perfect analysis
const fileDescriptions = [];
for (const file of files) {
const description = await this.generateAIPoweredChangeEntry(file);
if (description) {
// Extract just the description part (remove the "- (type) path - " prefix)
const match = description.match(/- \(\w+\) .+ - (.+) \(\d+%\)/);
if (match) {
fileDescriptions.push(match[1]);
}
}
}
// Intelligently summarize the individual descriptions
const summary = this.summarizeFileDescriptions(fileDescriptions, changeType, fileCount);
return `- (${changeType}) ${summary}`;
}
summarizeFileDescriptions(descriptions, changeType, fileCount) {
if (descriptions.length === 0) {
return `${changeType} changes affecting ${fileCount} files`;
}
// Extract specific, detailed functional changes and contexts
const specificChanges = [];
const _keyFiles = [];
const technologies = new Set();
const purposes = new Set();
for (const desc of descriptions) {
// Use the existing individual file analysis instead of hardcoded patterns
if (desc?.trim() && desc !== 'No description available') {
specificChanges.push(desc);
}
}
// If we have specific changes, create detailed description
if (specificChanges.length > 0) {
const uniqueChanges = [...new Set(specificChanges)];
// Create a detailed, specific description
if (uniqueChanges.length === 1) {
const change = uniqueChanges[0];
const techList = [...technologies];
if (techList.length > 0) {
return `${change} affecting ${techList.join(', ')} (${fileCount} files)`;
}
return `${change} (${fileCount} files)`;
}
if (uniqueChanges.length === 2) {
return `${uniqueChanges.join(' and ')} (${fileCount} files)`;
}
// For architectural changes affecting many files, show more detail
if (changeType === 'architectural' && fileCount >= 10) {
// Show first 5 changes for major architectural changes
const primary = uniqueChanges.slice(0, 5).join(', ');
const remaining = uniqueChanges.length - 5;
if (remaining > 0) {
return `${primary} and ${remaining} additional change${remaining > 1 ? 's' : ''} (${fileCount} files)`;
}
return `${primary} (${fileCount} files)`;
}
// For smaller changes, show more details instead of hiding them
if (uniqueChanges.length <= 5) {
// Show all changes if 5 or fewer - user wants to see exactly what each feature is
return `${uniqueChanges.join(', ')} (${fileCount} files)`;
}
// Show first 4 and remaining count for 6+ changes
const primary = uniqueChanges.slice(0, 4).join(', ');
const remaining = uniqueChanges.length - 4;
return `${primary} and ${remaining} additional enhancement${remaining > 1 ? 's' : ''} (${fileCount} files)`;
}
// Enhanced fallback with more context
const techList = [...technologies];
const purposeList = [...purposes];
if (techList.length > 0 && purposeList.length > 0) {
return `${changeType} changes to ${techList.slice(0, 2).join(', ')} for ${purposeList.slice(0, 2).join(', ')} (${fileCount} files)`;
}
if (techList.length > 0) {
return `${changeType} changes to ${techList.slice(0, 3).join(', ')} (${fileCount} files)`;
}
if (purposeList.length > 0) {
return `${changeType} changes for ${purposeList.slice(0, 3).join(', ')} (${fileCount} files)`;
}
// Final fallback
return `${changeType} changes affecting ${fileCount} files`;
}
isArchitecturalChange(path, _change) {
return (path === 'src/' ||
path.includes('lib/') ||
path.includes('bin/') ||
path.includes('package.json'));
}
isConfigurationFile(path) {
return (path.startsWith('.') ||
path.includes('config') ||
path.endsWith('.json') ||
path.endsWith('.env') ||
path.includes('package.json'));
}
isTestFile(path) {
return (path.includes('test') ||
path.includes('spec') ||
path.startsWith('test-') ||
path.includes('/test/'));
}
isDocumentationFile(path) {
return (path.endsWith('.md') ||
path.includes('docs') ||
path.includes('README') ||
path.includes('CHANGELOG'));
}
analyzeModifiedFileChanges(diff, path) {
const lines = diff.split('\n');
const addedLines = lines.filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const removedLines = lines.filter((line) => line.startsWith('-') && !line.startsWith('---'));
const addedContent = addedLines.map((line) => line.substring(1)).join('\n');
const removedContent = removedLines.map((line) => line.substring(1)).join('\n');
// Extract actual functionality changes instead of just counting
const functionalChanges = this.extractFunctionalityChanges(addedContent, removedContent, path);
if (functionalChanges && functionalChanges.length > 0) {
return functionalChanges.join(', ');
}
// Fallback to basic pattern analysis if no functional changes detected
const changes = [];
// Function changes
const addedFunctions = (addedContent.match(/function\s+\w+|const\s+\w+\s*=\s*\(/g) || []).length;
const removedFunctions = (removedContent.match(/function\s+\w+|const\s+\w+\s*=\s*\(/g) || [])
.length;
if (addedFunctions > 0) {
changes.push(`Added ${addedFunctions} function${addedFunctions > 1 ? 's' : ''}`);
}
if (removedFunctions > 0) {
changes.push(`removed ${removedFunctions} function${removedFunctions > 1 ? 's' : ''}`);
}
// Import/export changes
const addedImports = (addedContent.match(/import\s+.*?from|require\s*\(/g) || []).length;
const removedImports = (removedContent.match(/import\s+.*?from|require\s*\(/g) || []).length;
if (addedImports > 0) {
changes.push(`added ${addedImports} import${addedImports > 1 ? 's' : ''}`);
}
if (removedImports > 0) {
changes.push(`removed ${removedImports} import${removedImports > 1 ? 's' : ''}`);
}
// Class changes
const addedClasses = (addedContent.match(/class\s+\w+/g) || []).length;
const removedClasses = (removedContent.match(/class\s+\w+/g) || []).length;
if (addedClasses > 0) {
changes.push(`added ${addedClasses} class${addedClasses > 1 ? 'es' : ''}`);
}
if (removedClasses > 0) {
changes.push(`removed ${removedClasses} class${removedClasses > 1 ? 'es' : ''}`);
}
// Method changes within classes
const addedMethods = (addedContent.match(/\s+\w+\s*\([^)]*\)\s*{/g) || []).length;
const removedMethods = (removedContent.match(/\s+\w+\s*\([^)]*\)\s*{/g) || []).length;
if (addedMethods > 0) {
changes.push(`added ${addedMethods} method${addedMethods > 1 ? 's' : ''}`);
}
if (removedMethods > 0) {
changes.push(`removed ${removedMethods} method${removedMethods > 1 ? 's' : ''}`);
}
// Error handling changes
if (addedContent.includes('try') || addedContent.includes('catch')) {
changes.push('enhanced error handling');
}
// Configuration-specific changes
if (path?.includes('package.json')) {
if (addedContent.includes('"dependencies"')) {
changes.push('updated dependencies');
}
if (addedContent.includes('"scripts"')) {
changes.push('modified scripts');
}
}
return changes.length > 0 ? changes.slice(0, 3).join(', ') : null;
}
inferFileContent(path, diff) {
if (!path) {
return 'content';
}
const ext = path.split('.').pop()?.toLowerCase();
if (['js', 'ts', 'jsx', 'tsx'].includes(ext)) {
if (diff.includes('function') || diff.includes('const ') || diff.includes('class ')) {
return 'JavaScript/TypeScript code including functions and classes';
}
return 'JavaScript/TypeScript code';
}
if (ext === 'json') {
if (path.includes('package.json')) {
return 'package configuration and dependencies';
}
return 'JSON configuration data';
}
if (['md', 'txt'].includes(ext)) {
return 'documentation and text content';
}
if (['png', 'jpg', 'jpeg', 'gif', 'svg'].includes(ext)) {
return 'image or asset data';
}
if (['css', 'scss', 'less'].includes(ext)) {
return 'styling definitions';
}
return 'content';
}
extractSpecificChanges(diff, path) {
if (!diff || diff.length < 50) {
return null;
}
const addedLines = diff
.split('\n')
.filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const removedLines = diff
.split('\n')
.filter((line) => line.startsWith('-') && !line.startsWith('---'));
const changes = [];
// Check for function changes
const addedFunctions = addedLines.filter((line) => line.includes('function ') ||
(line.includes('const ') && line.includes('= (')) ||
line.includes('async '));
if (addedFunctions.length > 0) {
changes.push(`Added ${addedFunctions.length} new function${addedFunctions.length > 1 ? 's' : ''}`);
}
// Check for import changes
const addedImports = addedLines.filter((line) => line.includes('import ') || line.includes('require('));
const removedImports = removedLines.filter((line) => line.includes('import ') || line.includes('require('));
if (addedImports.length > 0) {
changes.push(`added ${addedImports.length} import${addedImports.length > 1 ? 's' : ''}`);
}
if (removedImports.length > 0) {
changes.push(`removed ${removedImports.length} import${removedImports.length > 1 ? 's' : ''}`);
}
// Check for package.json specific changes
if (path?.includes('package.json')) {
if (diff.includes('"dependencies"') || diff.includes('"devDependencies"')) {
changes.push('updated project dependencies');
}
if (diff.includes('"scripts"')) {
changes.push('modified build scripts');
}
}
return changes.length > 0 ? changes.slice(0, 2).join(' and ') : null;
}
extractFunctionalityChanges(addedContent, removedContent, path) {
const functionalChanges = [];
// Analyze what functionality was actually added or removed
if (removedContent.length > 50) {
// Only analyze substantial changes
// Connection/Testing functionality
if (removedContent.includes('testConnection') || removedContent.includes('test.success')) {
functionalChanges.push('removed connection testing functionality');
}
// Model availability checking
if (removedContent.includes('getAvailableModels') ||
removedContent.includes('getAvailable') ||
removedContent.includes('models.length')) {
functionalChanges.push('removed model availability checking');
}
// Authentication/Provider initialization
if (removedContent.includes('new AIProvider') ||
removedContent.includes('provider =') ||
removedContent.includes('Provider(')) {
functionalChanges.push('removed provider initialization');
}
// Error handling/validation
if (removedContent.includes('if (testResult.success)') ||
removedContent.includes('Connection test failed')) {
functionalChanges.push('removed error handling and validation');
}
// Configuration/Setup
if (removedContent.includes('require(') && addedContent.includes('skipping test')) {
functionalChanges.push('simplified configuration setup');
}
// Database/API operations
if ((removedContent.includes('await ') && removedContent.includes('database')) ||
removedContent.includes('api')) {
functionalChanges.push('removed database/API operations');
}
// UI/Display functionality
if (removedContent.includes('forEach') && removedContent.includes('console.log')) {
functionalChanges.push('removed dynamic model listing display');
}
// Authentication changes
if (removedContent.includes('auth') ||
removedContent.includes('token') ||
removedContent.includes('key')) {
functionalChanges.push('modified authentication handling');
}
}
// Analyze what was added
if (addedContent.length > 20) {
if (addedContent.includes('Configuration testing requires') ||
addedContent.includes('skipping test')) {
functionalChanges.push('added configuration migration notice');
}
if (addedContent.includes('--validate') || addedContent.includes('test your configuration')) {
functionalChanges.push('redirected testing to CLI --validate');
}
// New functionality patterns
if (addedContent.includes('async ') && addedContent.includes('function')) {
const newFunctions = (addedContent.match(/async\\s+function\\s+([\\w]+)/g) || []).map((f) => f.replace('async function ', ''));
if (newFunctions.length > 0) {
functionalChanges.push(`added ${newFunctions.slice(0, 2).join(', ')} async functionality`);
}
}
}
// File-specific analysis
if (path) {
if (path.includes('setup') &&
functionalChanges.length === 0 &&
removedContent.includes('test') &&
addedContent.includes('validate')) {
functionalChanges.push('replaced test functions with validation system');
}
if ((path.includes('provider') || path.includes('ai-')) &&
removedContent.includes('import') &&
addedContent.includes('moved to')) {
functionalChanges.push('refactored import paths for new architecture');
}
}
return functionalChanges.length > 0 ? functionalChanges : null;
}
extractFileDescriptionFromAI(aiSummary, change) {
// Extract meaningful description from AI analysis
if (!aiSummary || typeof aiSummary !== 'string') {
return this.analyzeDiffContentForDescription(change);
}
// Clean up AI response to be suitable for changelog entry
let description = aiSummary.trim();
// Remove generic prefixes
description = description.replace(/^(This change|The change|Change:|Summary:)\s*/i, '');
description = description.replace(/^(Analysis|Description):\s*/i, '');
// Take first sentence if it's a long response
const sentences = description.split(/[.!?]\s+/);
if (sentences.length > 1 && sentences[0].length > 20) {
description = sentences[0];
}
// Capitalize first letter
if (description.length > 0) {
description = description.charAt(0).toUpperCase() + description.slice(1);
}
// Fallback if AI response is too generic
if (description.length < 10 ||
description.toLowerCase().includes('file changed') ||
description.toLowerCase().includes('modified') ||
description.toLowerCase().includes('updated')) {
return this.analyzeDiffContentForDescription(change);
}
return description;
}
analyzeDiffContentForDescription(change) {
const path = change.path || change.filePath;
const status = change.status;
const diff = change.diff || '';
const category = change.category || 'other';
if (!diff || diff === 'Analysis failed') {
return this.generateChangeDescription(change);
}
// For modified files, use the enhanced functional analysis
if (status === 'M' && this.analyzeModifiedFileChanges) {
const functionalAnalysis = this.analyzeModifiedFileChanges(diff, path);
if (functionalAnalysis) {
return functionalAnalysis;
}
}
// For new files, use content analysis
if (status === 'A' || status === '??') {
const newFileAnalysis = this.analyzeNewFileContent(diff, path);
if (newFileAnalysis) {
return newFileAnalysis;
}
}
// For deleted files, analyze what was removed
if (status === 'D') {
const deletedFileAnalysis = this.analyzeDeletedFileContent(change);
if (deletedFileAnalysis) {
return deletedFileAnalysis;
}
}
// Fallback to old pattern-based analysis for other cases
const lines = diff.split('\n');
const addedLines = lines.filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const removedLines = lines.filter((line) => line.startsWith('-') && !line.startsWith('---'));
const addedContent = addedLines.map((line) => line.substring(1).trim()).join(' ');
const removedContent = removedLines.map((line) => line.substring(1).trim()).join(' ');
// Look for specific patterns in the actual changes
const changes = [];
// Function/method additions
const addedFunctions = this.extractFunctions(addedContent);
const removedFunctions = this.extractFunctions(removedContent);
if (addedFunctions.length > 0) {
changes.push(`Added ${addedFunctions.slice(0, 2).join(', ')} method${addedFunctions.length > 1 ? 's' : ''}`);
}
if (removedFunctions.length > 0) {
changes.push(`removed ${removedFunctions.slice(0, 2).join(', ')} method${removedFunctions.length > 1 ? 's' : ''}`);
}
// Import/export changes
if (addedContent.includes('import ') || addedContent.includes('require(')) {
const importCount = (addedContent.match(/import\s+/g) || []).length;
changes.push(`added ${importCount} import${importCount > 1 ? 's' : ''}`);
}
if (removedContent.includes('import ') || removedContent.includes('require(')) {
const importCount = (removedContent.match(/import\s+/g) || []).length;
changes.push(`removed ${importCount} import${importCount > 1 ? 's' : ''}`);
}
// Configuration changes
if (path?.includes('package.json')) {
const configChanges = this.analyzePackageJsonChanges(addedContent, removedContent);
if (configChanges.length > 0) {
changes.push(...configChanges);
}
}
// Error handling
if (addedContent.includes('try') ||
addedContent.includes('catch') ||
addedContent.includes('throw')) {
changes.push('enhanced error handling');
}
// Build meaningful description
if (changes.length > 0) {
const changeDesc = changes.slice(0, 3).join(', ');
return `Modified ${category} file with ${addedLines.length} additions and ${removedLines.length} deletions. ${changeDesc}`;
}
// Generic fallback with line counts
if (status === 'M' && (addedLines.length > 0 || removedLines.length > 0)) {
return `Modified ${category} file with ${addedLines.length} additions and ${removedLines.length} deletions`;
}
return this.generateChangeDescription(change);
}
calculateChangeConfidence(change) {
// Simple confidence calculation based on available data
let confidence = 70; // Base confidence
if (change.diff && change.diff.length > 50) {
confidence += 10;
}
if (change.semanticChanges?.patterns && change.semanticChanges.patterns.length > 0) {
confidence += 10;
}
if (change.complexity?.score) {
confidence += 5;
}
if (change.functionalImpact) {
confidence += 5;
}
return Math.min(confidence, 95);
}
generateDiffSummary(change) {
if (!change.diff || change.diff === 'Analysis failed') {
return null;
}
const diff = change.diff;
const status = change.status;
const filePath = change.path || change.filePath;
// Handle different file statuses
if (status === 'A' || status === '??') {
if (diff.includes('New file created with')) {
const lineMatch = diff.match(/(\d+) lines/);
const lines = lineMatch ? lineMatch[1] : 'unknown';
return `New file with ${lines} lines`;
}
return 'New file created';
}
if (status === 'D') {
return 'File deleted';
}
if (status === 'R') {
return 'File renamed';
}
// For modified files, analyze actual diff content
if (status === 'M' || status.includes('M')) {
const analysis = this.analyzeDiffContent(diff, filePath);
return analysis;
}
return null;
}
analyzeDiffContent(diff, filePath) {
const lines = diff.split('\n');
const addedLines = lines.filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const removedLines = lines.filter((line) => line.startsWith('-') && !line.startsWith('---'));
const changes = [];
// Analyze what was actually changed
const addedContent = addedLines.map((line) => line.substring(1).trim()).join(' ');
const removedContent = removedLines.map((line) => line.substring(1).trim()).join(' ');
// Function/method changes
const addedFunctions = this.extractFunctions(addedContent);
const removedFunctions = this.extractFunctions(removedContent);
if (addedFunctions.length > 0) {
changes.push(`added ${addedFunctions.slice(0, 2).join(', ')}`);
}
if (removedFunctions.length > 0) {
changes.push(`removed ${removedFunctions.slice(0, 2).join(', ')}`);
}
// Configuration changes
if (filePath?.includes('package.json')) {
const configChanges = this.analyzePackageJsonChanges(addedContent, removedContent);
if (configChanges.length > 0) {
changes.push(...configChanges);
}
}
// Import/export changes
const importChanges = this.analyzeImportChanges(addedContent, removedContent);
if (importChanges.length > 0) {
changes.push(...importChanges);
}
// Variable/constant changes
const variableChanges = this.analyzeVariableChanges(addedContent, removedContent);
if (variableChanges.length > 0) {
changes.push(...variableChanges);
}
// Error handling changes
const errorChanges = this.analyzeErrorHandling(addedContent, removedContent);
if (errorChanges.length > 0) {
changes.push(...errorChanges);
}
// Build summary with line counts
let summary = `+${addedLines.length}, -${removedLines.length} lines`;
if (changes.length > 0) {
summary += `: ${changes.slice(0, 3).join(', ')}`;
}
return summary;
}
extractFunctions(content) {
const functions = [];
const patterns = [
/function\s+(\w+)/g,
/(\w+)\s*\(/g,
/const\s+(\w+)\s*=\s*\(/g,
/async\s+(\w+)/g,
];
patterns.forEach((pattern) => {
let match = pattern.exec(content);
while (match !== null) {
if (match[1] && !functions.includes(match[1])) {
functions.push(match[1]);
}
match = pattern.exec(content);
}
});
return functions.slice(0, 3);
}
analyzePackageJsonChanges(added, _removed) {
const changes = [];
if (added.includes('"dependencies"') || added.includes('"devDependencies"')) {
changes.push('updated dependencies');
}
if (added.includes('"scripts"')) {
changes.push('updated scripts');
}
if (added.includes('"name"') || added.includes('"version"')) {
changes.push('updated metadata');
}
if (added.includes('"repository"') || added.includes('"author"')) {
changes.push('updated project info');
}
return changes;
}
analyzeImportChanges(added, removed) {
const changes = [];
if (added.includes('import') || added.includes('require(')) {
changes.push('added imports');
}
if (removed.includes('import') || removed.includes('require(')) {
changes.push('removed imports');
}
if (added.includes('export')) {
changes.push('added exports');
}
if (removed.includes('export')) {
changes.push('removed exports');
}
return changes;
}
analyzeVariableChanges(added, _removed) {
const changes = [];
if (added.includes('const ') || added.includes('let ') || added.includes('var ')) {
changes.push('added variables');
}
if (added.includes('= {') || added.includes('= [')) {
changes.push('updated data structures');
}
return changes;
}
analyzeErrorHandling(added, _removed) {
const changes = [];
if (added.includes('try') || added.includes('catch') || added.includes('throw')) {
changes.push('enhanced error handling');
}
if (added.includes('console.error') || added.includes('console.warn')) {
changes.push('added logging');
}
if (added.includes('if (') && added.includes('error')) {
changes.push('added error checks');
}
return changes;
}
buildChangelogFromAnalysis(analysis, changes, version) {
const timestamp = new Date().toISOString().split('T')[0];
let changelog = `# Working Directory Changes - ${timestamp}\n\n`;
if (version) {
changelog += `## Version ${version}\n\n`;
}
changelog += '### Summary\n';
changelog += `${analysis.summary || 'Working directory changes detected'}\n\n`;
changelog += `### Changes (${changes.length} files)\n`;
changes.forEach((change) => {
changelog += `- ${change.status} ${change.path}\n`;
});
return changelog;
}
analyzeDeletedFileContent(change) {
const filePath = change.path || change.filePath;
const beforeContent = change.beforeContent || '';
if (!beforeContent || beforeContent.trim() === '') {
return `Removed ${path.basename(filePath)} file from working directory`;
}
// Analyze what functionality was removed based on the file content
const language = change.language || this.detectLanguageFromPath(filePath);
const category = change.category || 'other';
if (language === 'javascript') {
return this.analyzeDeletedJavaScriptFile(beforeContent, filePath, category);
}
if (language === 'markdown') {
return this.analyzeDeletedMarkdownFile(beforeContent, filePath);
}
if (language === 'json') {
return this.analyzeDeletedJsonFile(beforeContent, filePath);
}
// Generic analysis for other file types
const lines = beforeContent.split('\n').length;
const fileName = path.basename(filePath);
const extension = path.extname(filePath);
if (category === 'source') {
return `Removed ${fileName} source file containing ${lines} lines of ${language || extension} code`;
}
if (category === 'documentation') {
return `Removed ${fileName} documentation file containing ${lines} lines`;
}
if (category === 'configuration') {
return `Removed ${fileName} configuration file`;
}
return `Removed ${fileName} file containing ${lines} lines`;
}
analyzeDeletedJavaScriptFile(content, filePath, _category) {
const fileName = path.basename(filePath);
// Extract functions, classes, and main features
const functions = this.extractFunctions(content);
const classes = this.extractClasses(content);
const imports = this.extractImports(content);
const exports = this.extractExports(content);
const features = [];
if (classes.length > 0) {
features.push(`${classes.length} class${classes.length > 1 ? 'es' : ''} (${classes.slice(0, 2).join(', ')})`);
}
if (functions.length > 0) {
features.push(`${functions.length} function${functions.length > 1 ? 's' : ''} (${functions.slice(0, 3).join(', ')})`);
}
if (imports.length > 0) {
features.push(`${imports.length} import${imports.length > 1 ? 's' : ''}`);
}
if (exports.length > 0) {
features.push(`${exports.length} export${exports.length > 1 ? 's' : ''}`);
}
// Determine the purpose based on file name and content patterns
let purpose = '';
if (fileName.includes('generator')) {
purpose = 'generator implementation';
}
else if (fileName.includes('provider')) {
purpose = 'provider implementation';
}
else if (fileName.includes('config')) {
purpose = 'configuration management';
}
else if (fileName.includes('manager') || fileName.includes('service')) {
purpose = 'service management';
}
else if (fileName.includes('cli') || fileName.includes('bin')) {
purpose = 'CLI functionality';
}
else if (content.includes('class ') && content.includes('constructor')) {
purpose = 'service class';
}
else if (content.includes('module.exports') || content.includes('export')) {
purpose = 'module';
}
else {
purpose = 'JavaScript module';
}
if (features.length > 0) {
return `Removed ${fileName} ${purpose} containing ${features.join(', ')}`;
}
const lines = content.split('\n').length;
return `Removed ${fileName} ${purpose} containing ${lines} lines of JavaScript code`;
}
analyzeDeletedMarkdownFile(content, filePath) {
const fileName = path.basename(filePath);
const lines = content.split('\n');
const sections = lines.filter((line) => line.startsWith('#')).length;
if (fileName.toLowerCase().includes('readme')) {
return `Removed ${fileName} project documentation containing ${sections} sections and ${lines.length} lines`;
}
if (fileName.toLowerCase().includes('changelog')) {
return `Removed ${fileName} changelog documentation`;
}
if (sections > 0) {
return `Removed ${fileName} documentation containing ${sections} sections`;
}
return `Removed ${fileName} documentation containing ${lines.length} lines`;
}
analyzeDeletedJsonFile(content, filePath) {
const fileName = path.basename(filePath);
try {
const parsed = JSON.parse(content);
const keys = Object.keys(parsed).length;
if (fileName === 'package.json') {
return `Removed package.json configuration with ${keys} properties (dependencies, metadata, scripts)`;
}
return `Removed ${fileName} configuration file with ${keys} configuration properties`;
}
catch {
return `Removed ${fileName} JSON configuration file`;
}
}
detectLanguageFromPath(filePath) {
const ext = path.extname(filePath).toLowerCase();
const languageMap = {
'.js': 'javascript',
'.ts': 'typescript',
'.json': 'json',
'.md': 'markdown',
'.py': 'python',
'.java': 'java',
'.css': 'css',
'.html': 'html',
'.yml': 'yaml',
'.yaml': 'yaml',
};
return languageMap[ext] || 'text';
}
// Missing methods expected by tests
generateMarkdownChangelog(data) {
return `# Changelog\n\n## Version ${data.version || 'Unreleased'}\n\n${data.content || 'No changes documented.'}`;
}
generateJSONChangelog(data) {
return JSON.stringify({
version: data.version || 'Unreleased',
changes: data.changes || [],
metadata: data.metadata || {},
}, null, 2);
}
generatePlainTextChangelog(data) {
return `Changelog - Version ${data.version || 'Unreleased'}\n\n${data.content || 'No changes documented.'}`;
}
// Workspace changelog methods (formerly in WorkspaceChangelogService)
async generateWorkspaceChangelog(version = null, options = {}) {
const result = await this.generateComprehensiveWorkspaceChangelog(options);
if (!result) {
return null;
}
let changelog = result.changelog;
// Add version information if provided
if (version) {
changelog = changelog.replace(/# Working Directory Changelog/, `# Working Directory Changelog - Version ${version}`);
}
// Add context information for detailed modes
if (options.analysisMode === 'detailed' || options.analysisMode === 'enterprise') {
changelog += this.generateContextSection(result.summary);
}
return {
...result,
changelog,
version,
};
}
async generateComprehensiveWorkspaceChangelog(options = {}) {
try {
// Get working directory changes as raw array
const rawChanges = getWorkingDirectoryChanges(this.getRepositoryCwd());
if (!(rawChanges && Array.isArray(rawChanges)) || rawChanges.length === 0) {
EnhancedConsole.info('No changes detected in working directory.');
return null;
}
// Enhanced analysis of changes with diff content for AI analysis
const enhancedChanges = await this.enhanceChangesWithDiff(rawChanges);
const changesSummary = summarizeFileChanges(enhancedChanges);
// Use DiffProcessor for intelligent processing
const analysisMode = options.analysisMode || 'standard';
const diffProcessor = new DiffProcessor({
analysisMode,
enableFiltering: true,
enablePatternDetection: true,
});
const processedResult = diffProcessor.processFiles(enhancedChanges);
// Generate changelog content with processed files. `options` is threaded so
// an explicit AI opt-out is honored here too โ previously the detailed and
// enterprise modes called the provider regardless of includeAIAnalysis.
const changelog = await this.generateWorkspaceChangelogContent(processedResult.processedFiles, changesSummary, processedResult, analysisMode, options);
return {
changelog,
changes: enhancedChanges,
processedFiles: processedResult.processedFiles,
patterns: processedResult.patterns,
summary: changesSummary,
filesProcessed: processedResult.filesProcessed,
filesSkipped: processedResult.filesSkipped,
};
}
catch (error) {
console.error(colors.errorMessage('Workspace changelog generation failed:'), error.message);
throw error;
}
}
async generateCommitStyleWorkingDirectoryEntries(options = {}) {
// Use provided working directory analysis or get current changes
let rawChanges;
if (options.workingDirAnalysis?.changes) {
rawChanges = options.workingDirAnalysis.changes;
}
else {
rawChanges = getWorkingDirectoryChanges(this.getRepositoryCwd());
}
try {
if (!(rawChanges && Array.isArray(rawChanges)) || rawChanges.length === 0) {
return { entries: [] };
}
// Enhanced analysis of changes with diff content for AI analysis
const enhancedChanges = await this.enhanceChangesWithDiff(rawChanges);
const changesSummary = summarizeFileChanges(enhancedChanges);
// Use DiffProcessor for intelligent diff processing
const analysisMode = this.aiAnalysisService?.analysisMode || options.analysisMode || 'standard';
const diffProcessor = new DiffProcessor({
analysisMode,
enableFiltering: true,
enablePatternDetection: true,
});
const processedResult = diffProcessor.processFiles(enhancedChanges);
const { processedFiles, patterns } = processedResult;
// Build pattern summary if patterns were detected
const patternSummary = Object.keys(patterns).length > 0
? `\n\n**BULK PATTERNS DETECTED:**\n${Object.values(patterns)
.map((p) => `- ${p.description}`)
.join('\n')}`
: '';
// Build files section with processed diffs
const filesSection = processedFiles
.map((file) => {
if (file.isSummary) {
return `\n**[REMAINING FILES]:** ${file.diff}`;
}
const compressionInfo = file.compressionApplied
? ` [compressed from ${file.originalSize || 'unknown'} chars]`
: '';
const patternInfo = file.bulkPattern ? ` [${file.bulkPattern}]` : '';
return `\n**${file.filePath || file.path}** (${file.status})${compressionInfo}${patternInfo}:\n${file.diff}`;
})
.join('\n');
// Build prompt for commit-style entries
const prompt = `Generate working directory change entries in the SAME FORMAT as git commits:
**Analysis Mode**: ${analysisMode}
**Total Files**: ${changesSummary.totalFiles} (${processedResult.filesProcessed} analyzed, ${processedResult.filesSkipped} summarized)
**Categories**: ${Object.keys(changesSummary.categories).join(', ')}${patternSummary}
**PROCESSED FILES:**${filesSection}
STRICT FORMATTING REQUIREMENTS:
Generate working directory change entries based ONLY on visible diff content:
- (type) Detailed but focused description - Include key functional changes, method/function names, and important technical details without overwhelming verbosity
Where:
- type = feature, fix, refactor, docs, chore, etc. based on the actual changes
- Detailed description = specific functions/methods affected, key technical changes, and functional purpose
- Include exact method names, variable names, and technical specifics from the diffs
EXAMPLES of CORRECT DETAILED FORMAT:
- (feature) Created new bedrock.js file - Added BedrockProvider class with generateCompletion(), initializeClient(), and getAvailableModels() methods. Imported AWS SDK BedrockRuntimeClient and added support for Claude Sonnet 5 and Llama 4 models with streaming capabilities.
- (refactor) Updated model list in anthropic.js - Changed getDefaultModel() return value from 'claude-sonnet-4-6' to 'claude-opus-5'. Added claude-opus-5 model entry with 1M context window and updated pricing tier.
- (fix) Updated configuration.manager.js - Added null check in getProviderConfig() method to prevent crashes when .env.local file is missing. Modified loadConfig() to gracefully handle missing environment files.
FORBIDDEN - DO NOT MAKE ASSUMPTIONS:
โ Do not mention "integration" unless you see actual integration code
โ Do not mention "provider selection logic" unless you see that specific code
โ Do not assume files work together unless explicitly shown in diffs
Generate one entry per file or logical change group. Only describe what you can literally see.`;
// Make AI call
const messages = [
{
role: 'system',
content: 'You are an expert at analyzing code changes and generating detailed but focused commit-style changelog entries. You MUST only describe changes that are visible in the provided diff content. Include specific function/method names, key technical details, and the functional purpose of changes. Be precise and factual - only describe what you can literally see in the diffs. Provide enough detail to understand what changed technically, but avoid overwhelming verbosity.',
},
{
role: 'user',
content: prompt,
},
];
// Set token limits based on analysis mode and number of changes
let maxTokens = 1200; // Default
if (analysisMode === 'enterprise') {
maxTokens = 3000;
}
else if (analysisMode === 'detailed') {
maxTokens = 2500;
}
// Increase token limit for large numbers of working directory changes
if (enhancedChanges.length > 50) {
maxTokens = Math.min(maxTokens + 1500, 6000);
}
const aiOptions = {
max_tokens: maxTokens,
temperature: 0.3,
};
const response = await this.aiAnalysisService.generateCompletion(messages, aiOptions);
const content = response.content || response.text;
// Check if content is valid before processing
if (!content || typeof content !== 'string') {
console.warn(colors.warningMessage('โ ๏ธ AI response was empty or invalid'));
console.warn(colors.infoMessage('๐ก Using basic file change detection instead'));
// Fallback to basic entries from the changes we were given
const fallbackChanges = rawChanges || getWorkingDirectoryChanges(this.getRepositoryCwd());
const basicEntries = fallbackChanges.map((change) => {
const filePath = change.filePath || change.path || 'unknown file';
const status = change.status || 'M';
const changeType = status === 'M'
? 'update'
: status === 'A'
? 'feature'
: status === 'D'
? 'remove'
: 'chore';
const changeDesc = status === 'M'
? 'updated'
: status === 'A'
? 'added'
: status === 'D'
? 'deleted'
: 'changed';
return `- (${changeType}) Modified ${filePath} - File ${changeDesc} (pattern-based analysis)`;
});
return { entries: basicEntries };
}
// Parse entries from response
const entries = content
.split('\n')
.filter((line) => {
const trimmed = line.trim();
// Accept lines starting with '- (' or directly with '(' for changelog entries
return trimmed.startsWith('- (') || trimmed.startsWith('(');
})
.map((line) => {
const trimmed = line.trim();
// Ensure all entries start with '- ' for consistent formatting
return trimmed.startsWith('- ') ? trimmed : `- ${trimmed}`;
});
return {
entries,
changes: enhancedChanges,
summary: changesSummary,
};
}
catch (error) {
// Provide specific guidance based on error type
if (error.message.includes('fetch failed') || error.message.includes('connection')) {
console.warn(colors.warningMessage('โ ๏ธ AI provider connection failed'));
console.warn(colors.infoMessage('๐ก Check your internet connection and provider configuration'));
}
else if (error.message.includes('API key') || error.message.includes('401')) {
console.warn(colors.warningMessage('โ ๏ธ Authentication failed'));
console.warn(colors.infoMessage('๐ก Run `ai-changelog init` to configure your API key'));
}
else {
console.warn(colors.warningMessage(`โ ๏ธ AI analysis failed: ${error.message}`));
console.warn(colors.infoMessage('๐ก Using basic file change detection instead'));
}
// Return basic entries from the changes we were given instead of getting fresh ones
const fallbackChanges = rawChanges || getWorkingDirectoryChanges(this.getRepositoryCwd());
const basicEntries = fallbackChanges.map((change) => {
const filePath = change.filePath || change.path || 'unknown file';
const status = change.status || 'M';
const changeType = status === 'M'
? 'update'
: status === 'A'
? 'feature'
: status === 'D'
? 'remove'
: 'chore';
const changeDesc = status === 'M'
? 'updated'
: status === 'A'
? 'added'
: status === 'D'
? 'deleted'
: 'changed';
return `- (${changeType}) Modified ${filePath} - File ${changeDesc} (pattern-based analysis)`;
});
return { entries: basicEntries };
}
}
async enhanceChangesWithDiff(changes) {
const enhancedChanges = [];
for (const change of changes) {
const enhancedChange = {
...change,
category: categorizeFile(change.path || change.filePath),
language: detectLanguage(change.path || change.filePath),
importance: assessFileImportance(change.path || change.filePath, change.status),
enhanced: true,
};
// Get diff content if git service is available
if (this.gitService) {
try {
const diffAnalysis = await this.gitService.analyzeWorkingDirectoryFileChange(change.status, change.path || change.filePath);
if (diffAnalysis) {
enhancedChange.diff = diffAnalysis.diff;
enhancedChange.beforeContent = diffAnalysis.beforeContent;
enhancedChange.afterContent = diffAnalysis.afterContent;
enhancedChange.semanticChanges = diffAnalysis.semanticChanges;
enhancedChange.functionalImpact = diffAnalysis.functionalImpact;
enhancedChange.complexity = diffAnalysis.complexity;
}
}
catch (error) {
console.warn(`Failed to get diff for ${change.path || change.filePath}:`, error.message);
}
}
enhancedChanges.push(enhancedChange);
}
return enhancedChanges;
}
async generateWorkspaceChangelogContent(changes, summary, _context, analysisMode, options = {}) {
// Standard mode is rule-based by design; the richer modes use AI only when the
// caller has not opted out. `includeAIAnalysis:false` must win over the mode.
const useAI = options.includeAIAnalysis !== false &&
(analysisMode === 'detailed' || analysisMode === 'enterprise');
if (useAI) {
return await this.generateAIChangelogContentFromChanges(changes, summary, analysisMode);
}
return this.generateBasicChangelogContentFromChanges(changes, summary);
}
async generateAIChangelogContentFromChanges(changes, changesSummary, analysisMode = 'standard') {
if (!this.aiAnalysisService.hasAI) {
console.log(colors.infoMessage('AI not available, using rule-based analysis...'));
return this.generateBasicChangelogContentFromChanges(changes, changesSummary);
}
try {
// Use DiffProcessor for intelligent diff processing
const diffProcessor = new DiffProcessor({
analysisMode,
enableFiltering: true,
enablePatternDetection: true,
});
const processedResult = diffProcessor.processFiles(changes);
const { processedFiles, patterns } = processedResult;
// Build pattern summary if patterns were detected
const patternSummary = Object.keys(patterns).length > 0
? `\n\n**BULK PATTERNS DETECTED:**\n${Object.values(patterns)
.map((p) => `- ${p.description}`)
.join('\n')}`
: '';
// Build files section with processed diffs
const filesSection = processedFiles
.map((file) => {
if (file.isSummary) {
return `\n**[REMAINING FILES]:** ${file.diff}`;
}
const compressionInfo = file.compressionApplied
? ` [compressed from ${file.originalSize || 'unknown'} chars]`
: '';
const patternInfo = file.bulkPattern ? ` [${file.bulkPattern}]` : '';
return `\n**${file.filePath || file.path}** (${file.status})${compressionInfo}${patternInfo}:\n${file.diff}`;
})
.join('\n');
// Build comprehensive prompt with processed changes
const prompt = `Generate a comprehensive AI changelog for the following working directory changes:
**Analysis Mode**: ${analysisMode}
**Total Files**: ${changesSummary.totalFiles} (${processedResult.filesProcessed} analyzed, ${processedResult.filesSkipped} summarized)
**Categories**: ${Object.keys(changesSummary.categories).join(', ')}${patternSummary}
**PROCESSED FILES:**${filesSection}
CRITICAL INSTRUCTIONS FOR ANALYSIS:
1. **ONLY DESCRIBE CHANGES VISIBLE IN THE DIFF CONTENT** - Do not invent or assume changes
2. **BE FACTUAL AND PRECISE** - Only mention specific lines, functions, imports that you can see
3. **NO ASSUMPTIONS OR SPECULATION** - If you can't see it in the diff, don't mention it
4. **STICK TO OBSERVABLE FACTS** - Describe what was added, removed, or modified line by line
5. **DO NOT MAKE UP INTEGRATION DETAILS** - Don't assume files work together unless explicitly shown
STRICT FORMATTING REQUIREMENTS:
Generate working directory change entries based ONLY on visible diff content:
- (type) Detailed but focused description - Include key functional changes, method/function names, and important technical details without overwhelming verbosity
EXAMPLES of CORRECT DETAILED FORMAT:
โ
(feature) Created new bedrock.js file - Added BedrockProvider class with generateCompletion(), initializeClient(), and getAvailableModels() methods. Imported AWS SDK BedrockRuntimeClient and added support for Claude Sonnet 5 and Llama 4 models with streaming capabilities.
โ
(refactor) Updated model list in anthropic.js - Changed getDefaultModel() return value from 'claude-sonnet-4-6' to 'claude-opus-5'. Added claude-opus-5 model entry with 1M context and updated pricing tier.
โ
(fix) Updated configuration.manager.js - Added null check in getProviderConfig() method to prevent crashes when .env.local file is missing. Modified loadConfig() to gracefully handle missing environment files.
EXAMPLES of FORBIDDEN ASSUMPTIONS:
โ "Updated other providers to recognize bedrock" (not visible in diff)
โ "Refactored provider selection logic" (not shown in actual changes)
โ "Improved integration across the system" (speculation)
โ "Enhanced error handling throughout" (assumption)
ONLY describe what you can literally see in the diff content. Do not invent connections or integrations.`;
// Make AI call with all the context
const messages = [
{
role: 'system',
content: 'You are an expert at analyzing code changes and generating detailed but focused changelog entries. You MUST only describe changes that are visible in the provided diff content. Include specific function/method names, key technical details, and the functional purpose of changes. Be precise and factual - only describe what you can literally see in the diffs. Provide enough detail to understand what changed technically, but avoid overwhelming verbosity.',
},
{
role: 'user',
content: prompt,
},
];
const options = {
max_tokens: analysisMode === 'enterprise' ? 2500 : analysisMode === 'detailed' ? 2000 : 1200,
temperature: 0.2,
};
const response = await this.aiAnalysisService.generateCompletion(messages, options);
let changelog = response.content || response.text;
// Check if changelog content is valid
if (!changelog || typeof changelog !== 'string') {
console.warn(colors.warningMessage('โ ๏ธ AI response was empty or invalid'));
console.warn(colors.infoMessage('๐ก Using basic file change detection instead'));
// Generate basic changelog from file changes
const timestamp = new Date().toISOString().split('T')[0];
const fallbackChanges = changes;
const basicEntries = fallbackChanges.map((change) => {
const filePath = change.filePath || change.path || 'unknown file';
const status = change.status || 'M';
const changeType = status === 'M'
? 'Modified'
: status === 'A'
? 'Added'
: status === 'D'
? 'Deleted'
: 'Changed';
return `- ${changeType} ${filePath}`;
});
changelog = `# Working Directory Changelog - ${timestamp}\n\n## Changes\n\n${basicEntries.join('\n')}`;
}
// Add metadata
const timestamp = new Date().toISOString().split('T')[0];
// Ensure proper changelog format with Keep a Changelog header
if (!changelog.includes('# ')) {
changelog = `# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased] - ${timestamp}\n\n${changelog}`;
}
// Add generation metadata
changelog += `\n\n---\n\n*Generated from ${changesSummary.totalFiles} working directory changes*\n`;
return changelog;
}
catch (error) {
// Specific error guidance for AI failures
if (error.message.includes('fetch failed') || error.message.includes('ECONNREFUSED')) {
console.warn(colors.warningMessage('โ ๏ธ Cannot connect to AI provider'));
console.warn(colors.infoMessage('๐ก Check internet connection and provider service status'));
}
else if (error.message.includes('API key') || error.message.includes('401')) {
console.warn(colors.warningMessage('โ ๏ธ API authentication failed'));
console.warn(colors.infoMessage('๐ก Run: ai-changelog init'));
}
else if (error.message.includes('rate limit')) {
console.warn(colors.warningMessage('โ ๏ธ Rate limit exceeded'));
console.warn(colors.infoMessage('๐ก Wait a moment before retrying'));
}
else {
console.warn(colors.warningMessage(`โ ๏ธ AI analysis failed: ${error.message}`));
}
console.warn(colors.infoMessage('๐ Falling back to pattern-based analysis'));
return this.generateBasicChangelogContentFromChanges(changes, changesSummary);
}
}
generateBasicChangelogContentFromChanges(changes, changesSummary) {
const timestamp = new Date().toISOString().split('T')[0];
let changelog = `# Working Directory Changes - ${timestamp}\n\n`;
// Basic summary
changelog += '## Summary\n';
changelog += `${changes.length} files modified across ${Object.keys(changesSummary.categories).length} categories.\n\n`;
// Changes by category
changelog += this.buildChangesByCategory(changes, changesSummary);
// Basic recommendations
changelog += '## Recommendations\n';
changelog += '- Review changes before committing\n';
changelog += '- Consider adding tests for new functionality\n';
changelog += '- Update documentation if needed\n\n';
return changelog;
}
buildChangesByCategory(_changes, changesSummary) {
let content = '## Changes by Category\n\n';
Object.entries(changesSummary.categories).forEach(([category, files]) => {
const categoryIcon = this.getCategoryIcon(category);
const categoryFiles = Array.isArray(files) ? files : [];
content += `### ${categoryIcon} ${category.charAt(0).toUpperCase() + category.slice(1)} (${categoryFiles.length} files)\n\n`;
categoryFiles.forEach((file) => {
const statusIcon = this.getStatusIcon(file.status);
content += `- ${statusIcon} ${file.path}\n`;
});
content += '\n';
});
return content;
}
getCategoryIcon(category) {
const icons = {
source: '๐ป',
tests: '๐งช',
documentation: '๐',
configuration: 'โ๏ธ',
frontend: '๐จ',
assets: '๐ผ๏ธ',
build: '๐ง',
other: '๐',
};
return icons[category] || '๐';
}
getStatusIcon(status) {
const icons = {
A: 'โ', // Added
M: 'โ๏ธ', // Modified
D: 'โ', // Deleted
R: '๐', // Renamed
C: '๐', // Copied
};
return icons[status] || '๐';
}
generateContextSection(context) {
if (!context) {
return '';
}
let section = '## Context Analysis\n\n';
// Handle totalFiles from either structure
if (context.totalFiles !== undefined) {
section += `- **Total Files:** ${context.totalFiles}\n`;
}
// Handle categories from summarizeFileChanges structure
if (context.categories && typeof context.categories === 'object') {
const categoryNames = Object.keys(context.categories);
if (categoryNames.length > 0) {
section += `- **Affected Areas:** ${categoryNames.join(', ')}\n`;
}
}
// Handle stats if available
if (context.stats) {
const statParts = [];
if (context.stats.added > 0)
statParts.push(`${context.stats.added} added`);
if (context.stats.modified > 0)
statParts.push(`${context.stats.modified} modified`);
if (context.stats.deleted > 0)
statParts.push(`${context.stats.deleted} deleted`);
if (statParts.length > 0) {
section += `- **Changes:** ${statParts.join(', ')}\n`;
}
}
// Handle languages if available
if (context.languages && Array.isArray(context.languages) && context.languages.length > 0) {
section += `- **Languages:** ${context.languages.join(', ')}\n`;
}
if (context.primaryCategory) {
section += `- **Primary Category:** ${context.primaryCategory}\n`;
}
if (context.riskLevel) {
section += `- **Risk Level:** ${context.riskLevel}\n`;
}
if (context.complexity) {
section += `- **Complexity:** ${context.complexity}\n`;
}
section += '\n';
if (context.recommendations &&
Array.isArray(context.recommendations) &&
context.recommendations.length > 0) {
section += '### Recommendations\n\n';
context.recommendations.forEach((rec) => {
section += `- ${rec}\n`;
});
section += '\n';
}
return section;
}
}