All files / packages/sds-release-manager/src/services release-notes-service.ts

96.38% Statements 80/83
91.89% Branches 68/74
100% Functions 8/8
97.29% Lines 72/74

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                              3x 3x 3x       116x     116x     58x   6x   110x       54x 54x   49x 54x 49x 54x 44x         49x 1x     48x 1798x 48x 46x 46x     46x 5x 5x 5x 5x 5x 5x 5x   41x 41x 41x     46x 46x 46x 46x 46x 800x 800x 28x 28x 772x 63x 63x   800x 800x 800x 800x   46x 46x 44x 44x 27x 27x 44x 44x   46x     27x     46x 54x 29x 26x   29x       7x                 13x 13x 13x 13x 11x   11x   11x    
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
 
export type PlannedChange = { added: string; content: string };
export type ReleaseNoteIcon = { icon_type: string; icon_name: string };
export type ReleaseNotesOptions = {
  version: string;
  date: string;
  icons?: ReleaseNoteIcon[];
  iconsVersion?: string;
};
 
export const GENERAL_NOTES_PATH = 'packages/design-system/RELEASENOTES.general.md';
export const COMPILED_NOTES_PATH = 'packages/design-system/RELEASENOTES.md';
export const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
 
export function releaseVersionBase(version: string): string {
  const match =
    /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$/.exec(
      version,
    );
  if (
    !match ||
    match.slice(1, 4).some((part) => !Number.isSafeInteger(Number(part))) ||
    match[4]?.split('.').some((part) => /^\d+$/.test(part) && !/^(0|[1-9]\d*)$/.test(part))
  ) {
    throw new Error(`Invalid release version: ${version}`);
  }
  return `${match[1]}.${match[2]}.${match[3]}`;
}
 
export function planReleaseNotesChange(existing: string, options: ReleaseNotesOptions): PlannedChange | null {
  const version = releaseVersionBase(options.version);
  Iif (!options.date?.trim() || /[\r\n]/.test(options.date))
    throw new Error('Release date/label must be a non-empty line');
  const icons = options.icons ?? [];
  Iif (icons.length > 0 && !options.iconsVersion) throw new Error('iconsVersion is required for icon notes');
  const iconsVersion = options.iconsVersion ? releaseVersionBase(options.iconsVersion) : undefined;
  for (const icon of icons) {
    Iif (!/^[a-z]+$/.test(icon.icon_type) || !/^[a-z0-9_]+$/.test(icon.icon_name)) {
      throw new Error('Invalid icon set/name for release notes');
    }
  }
 
  if (/^##\s*Release \d+\.\d+\.\d+\s-\s/gm.test(existing)) {
    throw new Error('Compiled release headings are not supported in GENERAL release notes');
  }
  // Match scripts/release-notes/general.js, treating CRLF as LF for parsing only.
  const headings = [...existing.matchAll(/^\##\s*(\d+\.\d+\.\d+)\s\-\s(.+)((?:\r?\n)+)$/gm)];
  const targets = headings.filter((heading) => heading[1] === version);
  if (targets.length > 1) throw new Error(`Duplicate release heading for ${version}`);
  let content = existing;
  let addedHeading = '';
  let sectionStart: number;
  let sectionEnd: number;
  if (targets.length === 0) {
    addedHeading = `## ${version} - ${options.date}`;
    const anchor = /<!-- On release, add general notes here[^>]*-->/.exec(existing);
    const insertAt = anchor ? anchor.index + anchor[0].length : (headings[0]?.index ?? existing.length);
    const prefix = existing.slice(0, insertAt);
    content = `${prefix}${prefix.endsWith('\n\n') || !prefix ? '' : prefix.endsWith('\n') ? '\n' : '\n\n'}${addedHeading}\n\n${existing.slice(insertAt)}`;
    sectionStart = content.indexOf(addedHeading, insertAt) + addedHeading.length;
    sectionEnd = sectionStart;
  } else {
    const heading = targets[0];
    sectionStart = heading.index! + heading[0].length - heading[3].length;
    sectionEnd = headings[headings.indexOf(heading) + 1]?.index ?? content.length;
  }
 
  const section = content.slice(sectionStart, sectionEnd);
  const seen = new Set<string>();
  let inVersion = false;
  let set = '';
  for (const line of section.split(/\r?\n/)) {
    const block = /^- Updated (?:`@salesforce-ux\/icons` to version|icons to) `v([^`]+)`$/.exec(line);
    if (block) {
      inVersion = block[1] === iconsVersion;
      set = '';
    } else if (/^\S/.test(line)) {
      inVersion = false;
      set = '';
    }
    const type = /^  - ([A-Za-z]+) [Ss]et:?$/.exec(line);
    if (type) set = type[1].toLowerCase();
    const name = /^    - Added ([a-z0-9_]+)$/.exec(line);
    if (inVersion && set && name) seen.add(`${set}/${name[1]}`);
  }
  const namesByType = new Map<string, string[]>();
  for (const icon of icons) {
    const key = `${icon.icon_type}/${icon.icon_name}`;
    if (seen.has(key)) continue;
    seen.add(key);
    const names = namesByType.get(icon.icon_type) ?? [];
    names.push(icon.icon_name);
    namesByType.set(icon.icon_type, names);
  }
  const sets = [...namesByType]
    .map(
      ([type, names]) =>
        `  - ${type.charAt(0).toUpperCase() + type.slice(1)} set\n${names.map((name) => `    - Added ${name}`).join('\n')}`,
    )
    .join('\n');
  const iconNotes = sets ? `- Updated \`@salesforce-ux/icons\` to version \`v${iconsVersion}\`\n${sets}` : '';
  if (!addedHeading && !iconNotes) return null;
  if (iconNotes) {
    content = `${content.slice(0, sectionStart)}\n\n${iconNotes}\n\n${content.slice(sectionStart).replace(/^\n+/, '')}`;
  }
  return { added: [addedHeading, iconNotes].filter(Boolean).join('\n\n'), content };
}
 
export async function compileReleaseNotes(repoRoot = defaultRepoRoot): Promise<void> {
  await promisify(execFile)(process.execPath, ['scripts/release-notes/index.js'], {
    cwd: path.join(repoRoot, 'packages/design-system'),
  });
}
 
export async function runReleaseNotes(
  options: ReleaseNotesOptions & { repoRoot?: string; dryRun?: boolean },
  dependencies: { compile?: (repoRoot: string) => Promise<void> } = {},
): Promise<{ version: string; change: PlannedChange | null }> {
  const repoRoot = options.repoRoot ?? defaultRepoRoot;
  const file = path.join(repoRoot, GENERAL_NOTES_PATH);
  const change = planReleaseNotesChange(await fs.readFile(file, 'utf8'), options);
  if (!options.dryRun) {
    if (change) await fs.writeFile(file, change.content, 'utf8');
    // Compile even on an authoring no-op: a previous compiler failure may have left stale output.
    await (dependencies.compile ?? compileReleaseNotes)(repoRoot);
  }
  return { version: releaseVersionBase(options.version), change };
}