246 lines · 9.6 KB · TypeScript
RawDownload
/** * 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');}