diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs index 0ef2400..1c43c38 100644 --- a/.github/scripts/check-changelog-exclusions.mjs +++ b/.github/scripts/check-changelog-exclusions.mjs @@ -3,7 +3,7 @@ * Used by the changelog-ci workflow to determine early if processing should continue */ -import { INCLUDED_TYPES } from "./update-changelog.mjs"; +import {INCLUDED_TYPES} from "./update-changelog.mjs"; /** * Labels that should exclude PRs from the changelog @@ -40,39 +40,12 @@ const ALL_COMMIT_TYPES = [...INCLUDED_TYPES, ...EXCLUDED_TYPES]; * Regex to match conventional commit type prefix in PR titles, * including both included and excluded types. */ -const typeRegex = new RegExp( - `^(${ALL_COMMIT_TYPES.join("|")})(\\(.+?\\))?!?:`, - "i", -); - -/** - * Checks if the PR has any labels that are in the EXCLUDED_LABELS list. - * The PR should be excluded from the changelog update process if any excluded label is found. - * - * @param {string[]} labels - Array of PR labels - * @returns {boolean} - True if any label is excluded - */ -function hasExcludedLabel(labels) { - return labels.some((label) => - EXCLUDED_LABELS.includes(label.name.toLowerCase()), - ); -} - -/** - * Gets the name of the excluded label, if any. - * @param {string[]} labels - Array of PR labels - * @returns {string|undefined} - The name of the excluded label, if any - */ -function getExcludedLabel(labels) { - return labels.find((label) => - EXCLUDED_LABELS.includes(label.name.toLowerCase()), - )?.name; -} +const typeRegex = new RegExp(`^(${ALL_COMMIT_TYPES.join("|")})(\\(.+?\\))?!?:`, "i"); /** * Checks if a PR should be excluded from the changelog */ -export default async function checkExclusions({ pr, core }) { +export default async function checkExclusions({pr, core}) { try { const prTitle = pr.title; @@ -96,9 +69,7 @@ export default async function checkExclusions({ pr, core }) { // If no conventional commit type is found, skip the PR. if (!match) { - console.log( - "⚠️ No conventional commit type found in PR title. Should skip.", - ); + console.log("⚠️ No conventional commit type found in PR title. Should skip."); shouldSkip = true; skipReason = "no conventional commit type"; } @@ -110,9 +81,7 @@ export default async function checkExclusions({ pr, core }) { // If the commit type is in the EXCLUDED_TYPES list, skip the PR. if (EXCLUDED_TYPES.includes(type)) { - console.log( - `⚠️ Conventional commit type "${type}" is excluded. Should skip.`, - ); + console.log(`⚠️ Conventional commit type "${type}" is excluded. Should skip.`); shouldSkip = true; skipReason = `excluded type: ${type}`; } @@ -135,3 +104,23 @@ export default async function checkExclusions({ pr, core }) { core.setFailed(`Failed to check exclusions: ${error.message}`); } } + +/** + * Checks if the PR has any labels that are in the EXCLUDED_LABELS list. + * The PR should be excluded from the changelog update process if any excluded label is found. + * + * @param {string[]} labels - Array of PR labels + * @returns {boolean} - True if any label is excluded + */ +function hasExcludedLabel(labels) { + return labels.some((label) => EXCLUDED_LABELS.includes(label.name.toLowerCase())); +} + +/** + * Gets the name of the excluded label, if any. + * @param {string[]} labels - Array of PR labels + * @returns {string|undefined} - The name of the excluded label, if any + */ +function getExcludedLabel(labels) { + return labels.find((label) => EXCLUDED_LABELS.includes(label.name.toLowerCase()))?.name; +} diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index 70b0ec7..94ae6ff 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -7,7 +7,7 @@ * before this script is called, so we can assume the PR should be included. */ -import { readFileSync, writeFileSync } from "fs"; +import {readFileSync, writeFileSync} from "fs"; /** * Maps conventional commit types to changelog sections @@ -62,50 +62,106 @@ export const INCLUDED_TYPES = Object.keys(TYPE_TO_SECTION); * Build regex pattern to match conventional commit type prefix * Matches: type(scope)?: or type!: with optional whitespace after colon */ -const COMMIT_TYPE_REGEX = new RegExp( - `^(${INCLUDED_TYPES.join("|")})(\\(.+?\\))?!?:\\s*`, - "i", -); +const COMMIT_TYPE_REGEX = new RegExp(`^(${INCLUDED_TYPES.join("|")})(\\(.+?\\))?!?:\\s*`, "i"); /** - * Extracts the conventional commit type from a PR title - * @param {string} title - PR title - * @returns {string|null} - The type or null if not found + * Main function to update the changelog + * + * @param {object} params An object containing the parameters for the function + * @param {object} params.pr Pull request object from GitHub context + * @param {import('@actions/core')} params.core GitHub Actions core module + * @param {import('@actions/github-script').AsyncFunctionArguments["context"]} params.context GitHub Actions context + * @param {import('@actions/github-script').AsyncFunctionArguments["github"]} params.github Octokit instance */ -function extractType(title) { - const match = title.match(COMMIT_TYPE_REGEX); - return match ? match[1].toLowerCase() : null; +export default async function updateChangelog({pr, core, context, github}) { + try { + const prNumber = pr.number; + const prTitle = pr.title; + const prUrl = pr.html_url; + const prAuthor = pr.user.login; + const prBody = pr.body; + + console.log(`📝 Processing PR #${prNumber}: ${prTitle}`); + + // Extract type from PR title + const type = extractType(prTitle); + if (!type) { + console.log(`⚠️ No valid conventional commit type found in PR title. Skipping changelog update.`); + return; + } + + const section = TYPE_TO_SECTION[type]; + console.log(`📂 Type: ${type} → Section: ${section}`); + + // Read current changelog + const changelogPath = "CHANGELOG.md"; + let changelog = ""; + try { + changelog = readFileSync(changelogPath, "utf8"); + } catch (error) { + console.log("CHANGELOG.md not found, creating new one"); + changelog = "# Changelog\n\n"; + } + + // Get or create Unreleased section + const {lines, unreleasedIndex} = findOrCreateUnreleased(changelog); + + // Check if this PR is already in the changelog + if (isDuplicateEntry(lines, unreleasedIndex, prNumber)) { + console.log(`ℹ️ PR #${prNumber} already exists in the changelog. Skipping.`); + return; + } + + // Format PR entry with cleaned title + const cleanedTitle = cleanTitle(prTitle); + const entry = await buildEntry(type, section, cleanedTitle, prNumber, prUrl, prAuthor, prBody, context, github); + + // Add entry to the appropriate section + const updatedLines = addEntryToSection(lines, unreleasedIndex, section, entry); + + // Write updated changelog + const updatedChangelog = updatedLines.join("\n"); + writeFileSync(changelogPath, updatedChangelog); + + console.log(`✅ Updated CHANGELOG.md with PR #${prNumber}`); + + // Set outputs for the workflow to use + core.setOutput("changelog-updated", "true"); + core.setOutput("pr-number", prNumber); + core.setOutput("pr-title", cleanedTitle); + core.setOutput("pr-author", prAuthor); + } catch (error) { + console.error("❌ Error updating changelog:", error); + core.setFailed(`Failed to update changelog: ${error.message}`); + } } /** - * Strips the conventional commit type prefix from a PR title - * @param {string} title - PR title - * @returns {string} - Cleaned title + * Extracts the conventional commit type from a PR title + * + * @param {string} title PR title + * @returns {string|null} The type or null if not found */ -function cleanTitle(title) { - // Remove the type prefix (e.g., "feat: ", "fix(scope): ") - const cleaned = title.replace(COMMIT_TYPE_REGEX, ""); - - if (cleaned.length === 0) return title; // Fallback to original if something went wrong - return cleaned; +function extractType(title) { + const match = title.match(COMMIT_TYPE_REGEX); + return match ? match[1].toLowerCase() : null; } /** * Gets or creates the Unreleased section in the changelog - * @param {string} changelog - Current changelog content - * @returns {object} - { hasUnreleased, lines, unreleasedIndex } + * + * @param {string} changelog Current changelog content + * @returns {{ hasUnreleased: boolean, lines: string[], unreleasedIndex: number }} */ function findOrCreateUnreleased(changelog) { const lines = changelog.split("\n"); const headerIndex = lines.findIndex((line) => line.startsWith("# Changelog")); // Find if Unreleased section exists - const unreleasedIndex = lines.findIndex((line) => - line.match(/^## \[?Unreleased\]?/i), - ); + const unreleasedIndex = lines.findIndex((line) => line.match(/^## \[?Unreleased\]?/i)); if (unreleasedIndex !== -1) { - return { hasUnreleased: true, lines, unreleasedIndex }; + return {hasUnreleased: true, lines, unreleasedIndex}; } // Create Unreleased section - find first release section to insert before it @@ -130,15 +186,16 @@ function findOrCreateUnreleased(changelog) { lines.splice(insertIndex, 0, ...unreleasedSection); - return { hasUnreleased: false, lines, unreleasedIndex: insertIndex + 1 }; + return {hasUnreleased: false, lines, unreleasedIndex: insertIndex + 1}; } /** * Checks if a PR entry already exists in the Unreleased section - * @param {array} lines - Changelog lines - * @param {number} unreleasedIndex - Index of Unreleased header - * @param {number} prNumber - PR number to check - * @returns {boolean} - True if PR already exists + * + * @param {array} lines Changelog lines + * @param {number} unreleasedIndex Index of Unreleased header + * @param {number} prNumber PR number to check + * @returns {boolean} True if PR already exists */ function isDuplicateEntry(lines, unreleasedIndex, prNumber) { // Find the next version header (##) or end of file @@ -161,13 +218,100 @@ function isDuplicateEntry(lines, unreleasedIndex, prNumber) { return false; } +/** + * Strips the conventional commit type prefix from a PR title + * + * @param {string} title PR title + * @returns {string} Cleaned title + */ +function cleanTitle(title) { + // Remove the type prefix (e.g., "feat: ", "fix(scope): ") + const cleaned = title.replace(COMMIT_TYPE_REGEX, ""); + + if (cleaned.length === 0) return title; // Fallback to original if something went wrong + return cleaned; +} + +/** + * Builds the full changelog entry line for a PR + * + * @param {string} type Conventional commit type (e.g., "feat", "fix", "revert") + * @param {string} section Section name resolved from TYPE_TO_SECTION + * @param {string} cleanedTitle PR title with the type prefix stripped + * @param {number} prNumber PR number + * @param {string} prUrl PR HTML URL + * @param {string} prAuthor PR author login + * @param {string|null} prBody PR body/description + * @param {import('@actions/github-script').AsyncFunctionArguments["context"]} context GitHub Actions context + * @param {import('@actions/github-script').AsyncFunctionArguments["github"]} github Octokit instance + * @returns {Promise} Formatted entry line + */ +async function buildEntry(type, section, cleanedTitle, prNumber, prUrl, prAuthor, prBody, context, github) { + const prefix = TYPE_TO_PREFIX[type] ?? section; + const dedupedTitle = removeLeadingDuplicateVerb(prefix, cleanedTitle); + const titlePart = dedupedTitle ? ` ${dedupedTitle}` : ` ${cleanedTitle.trim()}`; + const description = await formatPRDescription(prBody, context, github); + const prLink = `([#${prNumber}](${prUrl}))`; + const entryEnd = `\n`; + + return `- ${prefix}${titlePart} ${prLink} by @${prAuthor}${description}${entryEnd}`; +} + +/** + * Removes duplicated leading verbs based on the resolved changelog prefix. + * Example: prefix "Added" + title "added support for x" => "support for x" + * + * @param {string} prefix Resolved changelog entry prefix + * @param {string} title Cleaned PR title + * @returns {string} Title without duplicated leading verb + */ +function removeLeadingDuplicateVerb(prefix, title) { + const trimmedTitle = title.trim(); + if (!trimmedTitle) return ""; + + const pattern = PREFIX_TO_LEADING_VERB_REGEX[prefix.toLowerCase()]; + if (!pattern) return trimmedTitle; + + return trimmedTitle.replace(pattern, "").trimStart(); +} + +/** + * Formats the PR description with indentation for nesting under a list item + * + * @param {string|null} prBody PR description/body text + * @param {import('@actions/github-script').AsyncFunctionArguments["context"]} context GitHub Actions context + * @param {import('@actions/github-script').AsyncFunctionArguments["github"]} github Octokit instance + * @returns {Promise} Formatted description string (empty if no body) + */ +async function formatPRDescription(prBody, context, github) { + if (!prBody || prBody.trim() === "") { + return ""; + } + + // Convert markdown headings to bold text + const withoutHeadings = prBody.replace(/^#{1,6}\s+(.+)$/gm, "**$1**"); + + // Indent each line with 1 tab (4 spaces) to nest under the list item + // Skip indentation on empty lines to avoid trailing whitespace + const indented = withoutHeadings + .split("\n") + .map((line) => (line ? `${DESCRIPTION_INDENT}${line}` : "")) + .join("\n"); + // Always separate the description from the entry title with a blank line so + // that markdown renders the description on its own line. Strip any leading + // newlines from `indented` first to avoid double blank lines when prBody + // itself starts with a blank line. + return `\n\n${indented.replace(/^\n+/, "")}`; +} + /** * Adds a PR entry to the appropriate section within Unreleased - * @param {array} lines - Changelog lines - * @param {number} unreleasedIndex - Index of Unreleased header - * @param {string} section - Section name (Added, Fixed, etc.) - * @param {string} entry - PR entry to add - * @returns {array} - Updated lines + * + * @param {array} lines Changelog lines + * @param {number} unreleasedIndex Index of Unreleased header + * @param {string} section Section name (Added, Fixed, etc.) + * @param {string} entry PR entry to add + * @returns {array} Updated lines */ function addEntryToSection(lines, unreleasedIndex, section, entry) { // Find the next version header (##) or end of file @@ -199,18 +343,12 @@ function addEntryToSection(lines, unreleasedIndex, section, entry) { } // Skip all existing sections to add new section at the end - while ( - insertIndex < nextSectionIndex && - lines[insertIndex].startsWith("### ") - ) { + while (insertIndex < nextSectionIndex && lines[insertIndex].startsWith("### ")) { // Skip section header insertIndex++; // Skip all content until the next section header or end of Unreleased - while ( - insertIndex < nextSectionIndex && - !lines[insertIndex].startsWith("### ") - ) { + while (insertIndex < nextSectionIndex && !lines[insertIndex].startsWith("### ")) { insertIndex++; } } @@ -229,10 +367,7 @@ function addEntryToSection(lines, unreleasedIndex, section, entry) { // Skip existing entries using markers as definitive boundaries. // For entries without a marker (backward compatibility), stop at the next // entry title ("- ") or section header ("### "). - while ( - insertIndex < nextSectionIndex && - lines[insertIndex].startsWith("- ") - ) { + while (insertIndex < nextSectionIndex && lines[insertIndex].startsWith("- ")) { insertIndex++; // skip the entry title line // Advance past description lines/blank lines up to the marker while ( @@ -244,10 +379,7 @@ function addEntryToSection(lines, unreleasedIndex, section, entry) { insertIndex++; } // Skip the marker if present - if ( - insertIndex < nextSectionIndex && - lines[insertIndex] === "" - ) { + if (insertIndex < nextSectionIndex && lines[insertIndex] === "") { insertIndex++; } // Skip any blank lines between entries @@ -262,155 +394,3 @@ function addEntryToSection(lines, unreleasedIndex, section, entry) { return lines; } - -/** - * Formats the PR description with indentation for nesting under a list item - * @param {string|null} prBody - PR description/body text - * @returns {string} - Formatted description string (empty if no body) - */ -function formatPRDescription(prBody) { - if (!prBody || prBody.trim() === "") { - return ""; - } - - // Convert markdown headings to bold text - const withoutHeadings = prBody.replace(/^#{1,6}\s+(.+)$/gm, "**$1**"); - - // Indent each line with 1 tab (4 spaces) to nest under the list item - // Skip indentation on empty lines to avoid trailing whitespace - const indented = withoutHeadings - .split("\n") - .map((line) => (line ? `${DESCRIPTION_INDENT}${line}` : "")) - .join("\n"); - // Always separate the description from the entry title with a blank line so - // that markdown renders the description on its own line. Strip any leading - // newlines from `indented` first to avoid double blank lines when prBody - // itself starts with a blank line. - return `\n\n${indented.replace(/^\n+/, "")}`; -} - -/** - * Builds the full changelog entry line for a PR - * @param {string} type - Conventional commit type (e.g., "feat", "fix", "revert") - * @param {string} section - Section name resolved from TYPE_TO_SECTION - * @param {string} cleanedTitle - PR title with the type prefix stripped - * @param {number} prNumber - PR number - * @param {string} prUrl - PR HTML URL - * @param {string} prAuthor - PR author login - * @param {string|null} prBody - PR body/description - * @returns {string} - Formatted entry line - */ -function buildEntry( - type, - section, - cleanedTitle, - prNumber, - prUrl, - prAuthor, - prBody, -) { - const prefix = TYPE_TO_PREFIX[type] ?? section; - const dedupedTitle = removeLeadingDuplicateVerb(prefix, cleanedTitle); - const titlePart = dedupedTitle ? ` ${dedupedTitle}` : ` ${cleanedTitle.trim()}`; - return `- ${prefix}${titlePart} ([#${prNumber}](${prUrl})) by @${prAuthor}${formatPRDescription(prBody)}\n`; -} - -/** - * Removes duplicated leading verbs based on the resolved changelog prefix. - * Example: prefix "Added" + title "added support for x" => "support for x" - * @param {string} prefix - Resolved changelog entry prefix - * @param {string} title - Cleaned PR title - * @returns {string} - Title without duplicated leading verb - */ -function removeLeadingDuplicateVerb(prefix, title) { - const trimmedTitle = title.trim(); - if (!trimmedTitle) return ""; - - const pattern = PREFIX_TO_LEADING_VERB_REGEX[prefix.toLowerCase()]; - if (!pattern) return trimmedTitle; - - return trimmedTitle.replace(pattern, "").trimStart(); -} - -/** - * Main function to update the changelog - */ -export default async function updateChangelog({ pr, core }) { - try { - const prNumber = pr.number; - const prTitle = pr.title; - const prUrl = pr.html_url; - const prAuthor = pr.user.login; - const prBody = pr.body; - - console.log(`📝 Processing PR #${prNumber}: ${prTitle}`); - - // Extract type from PR title - const type = extractType(prTitle); - if (!type) { - console.log( - `⚠️ No valid conventional commit type found in PR title. Skipping changelog update.`, - ); - return; - } - - const section = TYPE_TO_SECTION[type]; - console.log(`📂 Type: ${type} → Section: ${section}`); - - // Read current changelog - const changelogPath = "CHANGELOG.md"; - let changelog = ""; - try { - changelog = readFileSync(changelogPath, "utf8"); - } catch (error) { - console.log("CHANGELOG.md not found, creating new one"); - changelog = "# Changelog\n\n"; - } - - // Get or create Unreleased section - const { lines, unreleasedIndex } = findOrCreateUnreleased(changelog); - - // Check if this PR is already in the changelog - if (isDuplicateEntry(lines, unreleasedIndex, prNumber)) { - console.log( - `ℹ️ PR #${prNumber} already exists in the changelog. Skipping.`, - ); - return; - } - - // Format PR entry with cleaned title - const cleanedTitle = cleanTitle(prTitle); - const entry = buildEntry( - type, - section, - cleanedTitle, - prNumber, - prUrl, - prAuthor, - prBody, - ); - - // Add entry to the appropriate section - const updatedLines = addEntryToSection( - lines, - unreleasedIndex, - section, - entry, - ); - - // Write updated changelog - const updatedChangelog = updatedLines.join("\n"); - writeFileSync(changelogPath, updatedChangelog); - - console.log(`✅ Updated CHANGELOG.md with PR #${prNumber}`); - - // Set outputs for the workflow to use - core.setOutput("changelog-updated", "true"); - core.setOutput("pr-number", prNumber); - core.setOutput("pr-title", cleanedTitle); - core.setOutput("pr-author", prAuthor); - } catch (error) { - console.error("❌ Error updating changelog:", error); - core.setFailed(`Failed to update changelog: ${error.message}`); - } -} diff --git a/.github/workflows/changelog-ci.yml b/.github/workflows/changelog-ci.yml index 6c2057b..42cf4a1 100644 --- a/.github/workflows/changelog-ci.yml +++ b/.github/workflows/changelog-ci.yml @@ -166,7 +166,9 @@ jobs: const pr = JSON.parse(Buffer.from(process.env.RESOLVED_PR_BASE64, 'base64').toString('utf8')); return await updateChangelog({ pr, - core + core, + context, + github }); env: RESOLVED_PR_BASE64: "${{ steps.resolve-pr.outputs.pr-json-base64 }}" diff --git a/.prettierrc.json b/.prettierrc.json index be0684a..83bd5a6 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -5,6 +5,7 @@ "printWidth": 150, "bracketSameLine": true, "bracketSpacing": false, + "multilineArraysWrapThreshold": 4, "overrides": [ { "files": [ @@ -34,5 +35,8 @@ "trailingComma": "es5" } } + ], + "plugins": [ + "prettier-plugin-multiline-arrays" ] } diff --git a/package.json b/package.json index 61c069d..b0e82fa 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,8 @@ "@types/node": "^22.9.0", "@types/vscode": "^1.110", "mocha": "^10.8.2", + "prettier": "^3.9.6", + "prettier-plugin-multiline-arrays": "^4.1.11", "typescript": "^5.7" }, "dependencies": {