/** * Commit message formatting utilities */ /** * Generate a concise first line for commit message. * Format: scope: description * When 1–3 files, append (file1, file2) so repeated commits with same pattern get distinct lines. */export function generateCommitFirstLine( fixedIssues: Array<{ filePath: string; comment: string }>, filePaths: string[]): string { const scope = determineScope(filePaths); const desc = extractDescription(fixedIssues, filePaths); let base = `${scope}: ${desc}`; if (base.length > 72) { base = `${base.slice(0, 69)}...`; } if (filePaths.length >= 1 && filePaths.length <= 3) { const names = filePaths.map((p) => p.split('/').pop() ?? p).filter(Boolean); const uniq = [...new Set(names)]; if (uniq.length > 0) { const withHint = `${base} (${uniq.join(', ')})`; if (withHint.length <= 72) return withHint; } } return base;} /** * Determine the scope (area of codebase) from file paths. * Examples: "auth", "api", "ui", "core" */function determineScope(filePaths: string[]): string { if (filePaths.length === 0) return 'misc'; // Count occurrences of each directory segment (exclude final path segment/leaf — filenames like package.json are not scope candidates) const scopeCounts = new Map<string, number>(); for (const path of filePaths) { const segments = path.split('/').filter(s => s && s !== '.' && s !== '..'); const dirSegments = segments.length > 1 ? segments.slice(0, -1) : []; // Skip top-level segments like 'src', 'lib', etc. const meaningfulSegments = dirSegments.filter(s => !['src', 'lib', 'dist', 'build', 'test', 'tests'].includes(s.toLowerCase()) ); if (meaningfulSegments.length > 0) { const scope = meaningfulSegments[0]; scopeCounts.set(scope, (scopeCounts.get(scope) || 0) + 1); } } // Find the most common scope let bestScope = 'misc'; let maxCount = 0; for (const [scope, count] of scopeCounts) { if (count > maxCount) { maxCount = count; bestScope = scope; } } // Pill-output: paths like `src/utils.ts` filter out `src` and become empty → avoid generic "misc" if (bestScope === 'misc' && filePaths.length > 0) { for (const path of filePaths) { const segments = path.split('/').filter((s) => s && s !== '.' && s !== '..'); const dirSegments = segments.length > 1 ? segments.slice(0, -1) : []; if (dirSegments.length > 0) { return dirSegments[0]!; } } } return bestScope;} /** * Build a file/scope-based fallback description (used when no pattern or title matches). */function fileBasedFallback(filePaths: string[]): string { if (filePaths.length === 0) return 'improve code quality'; const scope = determineScope(filePaths); const baseNames = filePaths .map((p) => p.split('/').pop()?.replace(/\.[^.]+$/, '') || 'file') .filter(Boolean); const uniq = [...new Set(baseNames)]; if (scope !== 'misc') return `fix issues in ${scope}`; if (uniq.length === 1) return `fix issues in ${uniq[0]}`; if (uniq.length === 2) return `fix issues in ${uniq[0]}, ${uniq[1]}`; return `fix issues across ${uniq.length} files`;} /** * Extract a description from review comments. * Prefers specific issue titles (e.g. "### Fix X") then pattern-based fallbacks. */function extractDescription( fixedIssues: Array<{ filePath: string; comment: string }>, filePaths: string[]): string { const fileBasedDesc = fileBasedFallback(filePaths); if (fixedIssues.length === 0) { return fileBasedDesc; } // Single file + test/coverage mention → specific line for history if (fixedIssues.length === 1 && filePaths.length === 1) { const comment = fixedIssues[0]!.comment.toLowerCase(); const base = filePaths[0]!.split('/').pop()?.replace(/\.[^.]+$/, '') || 'file'; if (/\btest\b|\bcoverage\b/.test(comment)) return `add tests for ${base}`; } // Prefer first line of first comment when it looks like a specific fix (### Title or Fix/Add/Remove...) const firstComment = fixedIssues[0]!.comment; const firstLine = firstComment.split(/\n/)[0]?.trim() ?? ''; const stripped = stripMarkdownForCommit(firstLine) .replace(/^#+\s*/, '') // drop ### prefix from bot titles .trim(); if (stripped.length >= 12 && stripped.length <= 72 && !/^https?:\/\//.test(stripped)) { const looksLikeFix = /^(fix|add|remove|improve|address|correct|prevent|handle|ensure)\s+/i.test(stripped) || /^(SIWE|credit|cleanup|race|time-window|stale|export|fallback)/i.test(stripped); if (looksLikeFix || fixedIssues.length === 1) { return stripped; } } // Combine all comments and look for specific patterns first (more specific before generic) const allText = fixedIssues.map(i => i.comment.toLowerCase()).join(' '); // allText is lowercased, so /i flag is redundant const patterns: Array<{ regex: RegExp; desc: string }> = [ { regex: /time-window|notbefore|expirationtime|chronological/, desc: 'fix SIWE time-window checks' }, { regex: /stale\s+credit|credit\s+balance|pre-credit/, desc: 'fix stale credit balance in signup response' }, { regex: /cleanup.*catch|usercreated.*try.*catch|block\s+scope/, desc: 'fix cleanup variable scope in signup' }, { regex: /race\s+condition\s+recovery|account\s+active\s+checks/, desc: 'add account active checks in race recovery' }, { regex: /organizationid\s+fallback|organization_id\s+\?\?/, desc: 'consistent organizationId fallback in handlers' }, { regex: /syncoptions\s+export|export.*syncoptions/, desc: 'restore SyncOptions export' }, { regex: /retry\s+cleanup|orphan.*organization/, desc: 'add retry cleanup for orphaned orgs' }, { regex: /add(ing)?\s+(uuid\s+)?validation/, desc: 'add validation' }, { regex: /add(ing)?\s+error\s+handling/, desc: 'add error handling' }, { regex: /add(ing)?\s+null\s+check/, desc: 'add null checks' }, { regex: /add(ing)?\s+auth(entication|orization)/, desc: 'add auth checks' }, { regex: /missing\s+(type|return|validation)/, desc: 'add missing types' }, { regex: /remove\s+(unused|dead)/, desc: 'remove unused code' }, // WHY "consolidate" not "remove duplicate code": Some review bots treat the latter as forbidden; same intent, safe wording. { regex: /duplicate/, desc: 'consolidate duplicate logic' }, { regex: /extract\s+(to|into)/, desc: 'extract shared code' }, { regex: /simplif(y|ied)/, desc: 'simplify implementation' }, { regex: /refactor/, desc: 'refactor for clarity' }, { regex: /performance|optimi[zs]/, desc: 'improve performance' }, { regex: /security|vulnerab/, desc: 'fix security issue' }, { regex: /race\s+condition/, desc: 'fix race condition' }, { regex: /memory\s+leak/, desc: 'fix memory leak' }, { regex: /exception|error\s+handling/, desc: 'improve error handling' }, { regex: /type\s+safety|typescript|:\s*string\s*\||:\s*number|add(ing)?\s+type\s+(safety|check)/, desc: 'add type safety' }, { regex: /test\s+cover|unit\s+test|mock|jest|vitest/, desc: 'add or fix tests' }, { regex: /lint|eslint|formatting|prettier/, desc: 'fix lint or formatting' }, { regex: /import\s+path|missing\s+import|wrong\s+import/, desc: 'fix imports' }, ]; for (const { regex, desc } of patterns) { if (regex.test(allText)) { return desc; } } // Try to extract specific noun phrases const nounMatch = allText.match(/(?:add|fix|improve|update|handle)\s+(\w+(?:\s+\w+)?)/); const nounPhrase = nounMatch?.[1]; if (nounPhrase && nounPhrase.length < 30) { return `${allText.includes('fix') ? 'fix' : 'improve'} ${nounPhrase}`; } return fileBasedDesc;} /** * Strip leading emoji / pictographic runs from review-bot comments (pill: eliza `src/commit.ts` themes). * Uses `\p{Extended_Pictographic}` so new prefixes are covered without a fixed alternation list. */function stripLeadingReviewEmojiPrefix(s: string): string { let out = s.trimStart(); const re = /^(?:[\s\uFEFF\u00AD\u200B-\u200D]*\p{Extended_Pictographic}[\uFE0F\u200D]*)+[\s\uFEFF]*/gu; for (let i = 0; i < 12; i++) { const next = out.replace(re, ''); if (next === out) break; out = next; } return out;} /** * Strip markdown/HTML formatting from text for use in commit messages */export function stripMarkdownForCommit(text: string): string { return stripLeadingReviewEmojiPrefix( text // Remove HTML tags .replace(/<[^>]+>/g, '') // Remove markdown emphasis (_text_, *text*, **text**, ~~text~~) .replace(/[_*~]{1,2}([^_*~]+)[_*~]{1,2}/g, '$1') // Remove markdown links [text](url) -> text .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'), ) // Collapse whitespace .replace(/\s+/g, ' ') .trim();} export function buildCommitMessage( issuesFixed: Array<{ filePath: string; comment: string }>, lessonsLearned: string[]): string { // Generate a meaningful first line const firstLine = generateCommitFirstLine(issuesFixed, issuesFixed.map(i => i.filePath)); const lines: string[] = [firstLine, '']; if (issuesFixed.length > 0) { lines.push('Changes:'); for (const issue of issuesFixed) { const fileName = issue.filePath.split('/').pop() || issue.filePath; // Truncate long comments for commit body const cleanComment = stripMarkdownForCommit(issue.comment).replace(/\r?\n/g, ' '); const truncatedComment = cleanComment.length > 80 ? cleanComment.slice(0, 77) + '...' : cleanComment; lines.push(`- ${fileName}: ${truncatedComment}`); } lines.push(''); } if (lessonsLearned.length > 0) { lines.push('Notes:'); for (const lesson of lessonsLearned) { lines.push(`- ${stripMarkdownForCommit(lesson).replace(/\r?\n/g, ' ')}`); } } return lines.join('\n');}
/**
* Commit message formatting utilities
*/
/**
* Generate a concise first line for commit message.
* Format: scope: description
* When 1–3 files, append (file1, file2) so repeated commits with same pattern get distinct lines.
*/
export function generateCommitFirstLine(
fixedIssues: Array<{ filePath: string; comment: string }>,
filePaths: string[]
): string {
const scope = determineScope(filePaths);
const desc = extractDescription(fixedIssues, filePaths);
let base = `${scope}: ${desc}`;
if (base.length > 72) {
base = `${base.slice(0, 69)}...`;
}
if (filePaths.length >= 1 && filePaths.length <= 3) {
const names = filePaths.map((p) => p.split('/').pop() ?? p).filter(Boolean);
const uniq = [...new Set(names)];
if (uniq.length > 0) {
const withHint = `${base} (${uniq.join(', ')})`;
if (withHint.length <= 72) return withHint;
}
}
return base;
}
/**
* Determine the scope (area of codebase) from file paths.
* Examples: "auth", "api", "ui", "core"
*/
function determineScope(filePaths: string[]): string {
if (filePaths.length === 0) return 'misc';
// Count occurrences of each directory segment (exclude final path segment/leaf — filenames like package.json are not scope candidates)
const scopeCounts = new Map<string, number>();
for (const path of filePaths) {
const segments = path.split('/').filter(s => s && s !== '.' && s !== '..');
const dirSegments = segments.length > 1 ? segments.slice(0, -1) : [];
// Skip top-level segments like 'src', 'lib', etc.
const meaningfulSegments = dirSegments.filter(s =>
!['src', 'lib', 'dist', 'build', 'test', 'tests'].includes(s.toLowerCase())
);
if (meaningfulSegments.length > 0) {
const scope = meaningfulSegments[0];
scopeCounts.set(scope, (scopeCounts.get(scope) || 0) + 1);
}
}
// Find the most common scope
let bestScope = 'misc';
let maxCount = 0;
for (const [scope, count] of scopeCounts) {
if (count > maxCount) {
maxCount = count;
bestScope = scope;
}
}
// Pill-output: paths like `src/utils.ts` filter out `src` and become empty → avoid generic "misc"
if (bestScope === 'misc' && filePaths.length > 0) {
for (const path of filePaths) {
const segments = path.split('/').filter((s) => s && s !== '.' && s !== '..');
const dirSegments = segments.length > 1 ? segments.slice(0, -1) : [];
if (dirSegments.length > 0) {
return dirSegments[0]!;
}
}
}
return bestScope;
}
/**
* Build a file/scope-based fallback description (used when no pattern or title matches).
*/
function fileBasedFallback(filePaths: string[]): string {
if (filePaths.length === 0) return 'improve code quality';
const scope = determineScope(filePaths);
const baseNames = filePaths
.map((p) => p.split('/').pop()?.replace(/\.[^.]+$/, '') || 'file')
.filter(Boolean);
const uniq = [...new Set(baseNames)];
if (scope !== 'misc') return `fix issues in ${scope}`;
if (uniq.length === 1) return `fix issues in ${uniq[0]}`;
if (uniq.length === 2) return `fix issues in ${uniq[0]}, ${uniq[1]}`;
return `fix issues across ${uniq.length} files`;
}
/**
* Extract a description from review comments.
* Prefers specific issue titles (e.g. "### Fix X") then pattern-based fallbacks.
*/
function extractDescription(
fixedIssues: Array<{ filePath: string; comment: string }>,
filePaths: string[]
): string {
const fileBasedDesc = fileBasedFallback(filePaths);
if (fixedIssues.length === 0) {
return fileBasedDesc;
}
// Single file + test/coverage mention → specific line for history
if (fixedIssues.length === 1 && filePaths.length === 1) {
const comment = fixedIssues[0]!.comment.toLowerCase();
const base = filePaths[0]!.split('/').pop()?.replace(/\.[^.]+$/, '') || 'file';
if (/\btest\b|\bcoverage\b/.test(comment)) return `add tests for ${base}`;
}
// Prefer first line of first comment when it looks like a specific fix (### Title or Fix/Add/Remove...)
const firstComment = fixedIssues[0]!.comment;
const firstLine = firstComment.split(/\n/)[0]?.trim() ?? '';
const stripped = stripMarkdownForCommit(firstLine)
.replace(/^#+\s*/, '') // drop ### prefix from bot titles
.trim();
if (stripped.length >= 12 && stripped.length <= 72 && !/^https?:\/\//.test(stripped)) {
const looksLikeFix = /^(fix|add|remove|improve|address|correct|prevent|handle|ensure)\s+/i.test(stripped) ||
/^(SIWE|credit|cleanup|race|time-window|stale|export|fallback)/i.test(stripped);
if (looksLikeFix || fixedIssues.length === 1) {
return stripped;
}
}
// Combine all comments and look for specific patterns first (more specific before generic)
const allText = fixedIssues.map(i => i.comment.toLowerCase()).join(' ');
// allText is lowercased, so /i flag is redundant
const patterns: Array<{ regex: RegExp; desc: string }> = [
{ regex: /time-window|notbefore|expirationtime|chronological/, desc: 'fix SIWE time-window checks' },
{ regex: /stale\s+credit|credit\s+balance|pre-credit/, desc: 'fix stale credit balance in signup response' },
{ regex: /cleanup.*catch|usercreated.*try.*catch|block\s+scope/, desc: 'fix cleanup variable scope in signup' },
{ regex: /race\s+condition\s+recovery|account\s+active\s+checks/, desc: 'add account active checks in race recovery' },
{ regex: /organizationid\s+fallback|organization_id\s+\?\?/, desc: 'consistent organizationId fallback in handlers' },
{ regex: /syncoptions\s+export|export.*syncoptions/, desc: 'restore SyncOptions export' },
{ regex: /retry\s+cleanup|orphan.*organization/, desc: 'add retry cleanup for orphaned orgs' },
{ regex: /add(ing)?\s+(uuid\s+)?validation/, desc: 'add validation' },
{ regex: /add(ing)?\s+error\s+handling/, desc: 'add error handling' },
{ regex: /add(ing)?\s+null\s+check/, desc: 'add null checks' },
{ regex: /add(ing)?\s+auth(entication|orization)/, desc: 'add auth checks' },
{ regex: /missing\s+(type|return|validation)/, desc: 'add missing types' },
{ regex: /remove\s+(unused|dead)/, desc: 'remove unused code' },
// WHY "consolidate" not "remove duplicate code": Some review bots treat the latter as forbidden; same intent, safe wording.
{ regex: /duplicate/, desc: 'consolidate duplicate logic' },
{ regex: /extract\s+(to|into)/, desc: 'extract shared code' },
{ regex: /simplif(y|ied)/, desc: 'simplify implementation' },
{ regex: /refactor/, desc: 'refactor for clarity' },
{ regex: /performance|optimi[zs]/, desc: 'improve performance' },
{ regex: /security|vulnerab/, desc: 'fix security issue' },
{ regex: /race\s+condition/, desc: 'fix race condition' },
{ regex: /memory\s+leak/, desc: 'fix memory leak' },
{ regex: /exception|error\s+handling/, desc: 'improve error handling' },
{ regex: /type\s+safety|typescript|:\s*string\s*\||:\s*number|add(ing)?\s+type\s+(safety|check)/, desc: 'add type safety' },
{ regex: /test\s+cover|unit\s+test|mock|jest|vitest/, desc: 'add or fix tests' },
{ regex: /lint|eslint|formatting|prettier/, desc: 'fix lint or formatting' },
{ regex: /import\s+path|missing\s+import|wrong\s+import/, desc: 'fix imports' },
];
for (const { regex, desc } of patterns) {
if (regex.test(allText)) {
return desc;
}
}
// Try to extract specific noun phrases
const nounMatch = allText.match(/(?:add|fix|improve|update|handle)\s+(\w+(?:\s+\w+)?)/);
const nounPhrase = nounMatch?.[1];
if (nounPhrase && nounPhrase.length < 30) {
return `${allText.includes('fix') ? 'fix' : 'improve'} ${nounPhrase}`;
}
return fileBasedDesc;
}
/**
* Strip leading emoji / pictographic runs from review-bot comments (pill: eliza `src/commit.ts` themes).
* Uses `\p{Extended_Pictographic}` so new prefixes are covered without a fixed alternation list.
*/
function stripLeadingReviewEmojiPrefix(s: string): string {
let out = s.trimStart();
const re =
/^(?:[\s\uFEFF\u00AD\u200B-\u200D]*\p{Extended_Pictographic}[\uFE0F\u200D]*)+[\s\uFEFF]*/gu;
for (let i = 0; i < 12; i++) {
const next = out.replace(re, '');
if (next === out) break;
out = next;
}
return out;
}
/**
* Strip markdown/HTML formatting from text for use in commit messages
*/
export function stripMarkdownForCommit(text: string): string {
return stripLeadingReviewEmojiPrefix(
text
// Remove HTML tags
.replace(/<[^>]+>/g, '')
// Remove markdown emphasis (_text_, *text*, **text**, ~~text~~)
.replace(/[_*~]{1,2}([^_*~]+)[_*~]{1,2}/g, '$1')
// Remove markdown links [text](url) -> text
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
)
// Collapse whitespace
.replace(/\s+/g, ' ')
.trim();
}
export function buildCommitMessage(
issuesFixed: Array<{ filePath: string; comment: string }>,
lessonsLearned: string[]
): string {
// Generate a meaningful first line
const firstLine = generateCommitFirstLine(issuesFixed, issuesFixed.map(i => i.filePath));
const lines: string[] = [firstLine, ''];
if (issuesFixed.length > 0) {
lines.push('Changes:');
for (const issue of issuesFixed) {
const fileName = issue.filePath.split('/').pop() || issue.filePath;
// Truncate long comments for commit body
const cleanComment = stripMarkdownForCommit(issue.comment).replace(/\r?\n/g, ' ');
const truncatedComment = cleanComment.length > 80
? cleanComment.slice(0, 77) + '...'
: cleanComment;
lines.push(`- ${fileName}: ${truncatedComment}`);
}
lines.push('');
}
if (lessonsLearned.length > 0) {
lines.push('Notes:');
for (const lesson of lessonsLearned) {
lines.push(`- ${stripMarkdownForCommit(lesson).replace(/\r?\n/g, ' ')}`);
}
}
return lines.join('\n');
}