From 3747f670e2ef1f7b04d45ec1e938e856313aad8a Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 02:56:06 +0100 Subject: [PATCH 01/13] style: auto formatting --- .github/scripts/update-changelog.mjs | 36 ++++++++-------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index 70b0ec7..0802e15 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,10 +62,8 @@ 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 @@ -100,12 +98,10 @@ function findOrCreateUnreleased(changelog) { 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,7 +126,7 @@ function findOrCreateUnreleased(changelog) { lines.splice(insertIndex, 0, ...unreleasedSection); - return { hasUnreleased: false, lines, unreleasedIndex: insertIndex + 1 }; + return {hasUnreleased: false, lines, unreleasedIndex: insertIndex + 1}; } /** @@ -199,18 +195,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 +219,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 +231,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 From a4b534a96627764c43ebe3e99f2fdbfb95c91c50 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 02:57:42 +0100 Subject: [PATCH 02/13] style: re-order functions in the update-changelog CI script for readability --- .github/scripts/update-changelog.mjs | 308 +++++++++++++-------------- 1 file changed, 143 insertions(+), 165 deletions(-) diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index 0802e15..c49137b 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -64,6 +64,75 @@ export const INCLUDED_TYPES = Object.keys(TYPE_TO_SECTION); */ const COMMIT_TYPE_REGEX = new RegExp(`^(${INCLUDED_TYPES.join("|")})(\\(.+?\\))?!?:\\s*`, "i"); +/** + * 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 + */ +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}`); + } +} /** * Extracts the conventional commit type from a PR title @@ -75,19 +144,6 @@ function extractType(title) { return match ? match[1].toLowerCase() : null; } -/** - * 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; -} - /** * Gets or creates the Unreleased section in the changelog * @param {string} changelog - Current changelog content @@ -157,6 +213,80 @@ 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 + * @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(); +} + +/** + * 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+/, "")}`; +} + /** * Adds a PR entry to the appropriate section within Unreleased * @param {array} lines - Changelog lines @@ -246,155 +376,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}`); - } -} From 2ebd21730d96b9e4ceac28446cd2beba57097e61 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 03:15:12 +0100 Subject: [PATCH 03/13] ci: add `context` and `github` params and change functions to `async`. - Added `context` and `github` params to: - The main `updateChangelog` function passing them from it's call in the CI step. - `buildEntry` function - `formatPRDescription` function. - The corresponding docblocks. - Refactored `buildEntry` function so the return statement isn't one long string. Split multiple sections into variables for ease. - Changed `formatPRDescription` and `buildEntry` functions to be `async`, and their function calls now `await` them. --- .github/scripts/update-changelog.mjs | 25 +++++++++++++++++-------- .github/workflows/changelog-ci.yml | 4 +++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index c49137b..62be6bd 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -70,8 +70,10 @@ const COMMIT_TYPE_REGEX = new RegExp(`^(${INCLUDED_TYPES.join("|")})(\\(.+?\\))? * @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 */ -export default async function updateChangelog({pr, core}) { +export default async function updateChangelog({pr, core, context, github}) { try { const prNumber = pr.number; const prTitle = pr.title; @@ -112,7 +114,7 @@ export default async function updateChangelog({pr, core}) { // Format PR entry with cleaned title const cleanedTitle = cleanTitle(prTitle); - const entry = buildEntry(type, section, cleanedTitle, prNumber, prUrl, prAuthor, prBody); + 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); @@ -235,13 +237,19 @@ function cleanTitle(title) { * @param {string} prUrl - PR HTML URL * @param {string} prAuthor - PR author login * @param {string|null} prBody - PR body/description - * @returns {string} - Formatted entry line + * @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 */ -function buildEntry(type, section, cleanedTitle, prNumber, prUrl, prAuthor, prBody) { +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()}`; - return `- ${prefix}${titlePart} ([#${prNumber}](${prUrl})) by @${prAuthor}${formatPRDescription(prBody)}\n`; + const description = await formatPRDescription(prBody, context, github); + const prLink = `([#${prNumber}](${prUrl}))`; + const entryEnd = `\n`; + + return `- ${prefix}${titlePart} ${prLink} by @${prAuthor}${description}${entryEnd}`; } /** @@ -263,10 +271,11 @@ function removeLeadingDuplicateVerb(prefix, title) { /** * 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) + * @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) */ -function formatPRDescription(prBody) { +async function formatPRDescription(prBody, context, github) { if (!prBody || prBody.trim() === "") { return ""; } 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 }}" From 33e1f390323caf326444b342afbeb2fc5a4d7843 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 03:20:16 +0100 Subject: [PATCH 04/13] ci: update docblocks of the update changelog CI script. - Remove the `-` in the params description because it's rendered as a bullet point in vscode intellisense. - Updated the return type of `findOrCreateUnreleased` function. --- .github/scripts/update-changelog.mjs | 59 ++++++++++++++++------------ 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index 62be6bd..94ae6ff 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -138,8 +138,9 @@ export default async function updateChangelog({pr, core, context, github}) { /** * Extracts the conventional commit type from a PR title - * @param {string} title - PR title - * @returns {string|null} - The type or null if not found + * + * @param {string} title PR title + * @returns {string|null} The type or null if not found */ function extractType(title) { const match = title.match(COMMIT_TYPE_REGEX); @@ -148,8 +149,9 @@ function extractType(title) { /** * 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"); @@ -189,10 +191,11 @@ function findOrCreateUnreleased(changelog) { /** * 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 @@ -217,8 +220,9 @@ function isDuplicateEntry(lines, unreleasedIndex, prNumber) { /** * Strips the conventional commit type prefix from a PR title - * @param {string} title - PR title - * @returns {string} - Cleaned title + * + * @param {string} title PR title + * @returns {string} Cleaned title */ function cleanTitle(title) { // Remove the type prefix (e.g., "feat: ", "fix(scope): ") @@ -230,13 +234,14 @@ function cleanTitle(title) { /** * 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 {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 @@ -255,9 +260,10 @@ async function buildEntry(type, section, cleanedTitle, prNumber, prUrl, prAuthor /** * 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 + * + * @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(); @@ -271,6 +277,8 @@ function removeLeadingDuplicateVerb(prefix, title) { /** * 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) @@ -298,11 +306,12 @@ async function formatPRDescription(prBody, context, github) { /** * 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 From 07a1de1fde9f869838b164cb1d7bf4f231e03b34 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 04:30:04 +0100 Subject: [PATCH 05/13] build: added dev dependencies to allow prettier to wrap arrays onto multi-lines. - Added the `prettier-plugin-multiline-arrays` dev dependency to force prettier to wrap arrays onto muliple lines. - Added the prettier dev dependency because the prettier vscode extension only supports prettier plugins when installed locally in a project. - Specified the prettier plugin and it's `multilineArraysWrapThreshold` option in the prettierrc.json file to enable the usage of the plugin. --- .prettierrc.json | 4 ++++ package.json | 2 ++ 2 files changed, 6 insertions(+) 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": { From 8cf4cf1cee9796bbb674c22f2bd75fd05da1c248 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 04:37:15 +0100 Subject: [PATCH 06/13] style: auto formatting --- .github/scripts/check-changelog-exclusions.mjs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs index 0ef2400..fcb6623 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,10 +40,7 @@ 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", -); +const typeRegex = new RegExp(`^(${ALL_COMMIT_TYPES.join("|")})(\\(.+?\\))?!?:`, "i"); /** * Checks if the PR has any labels that are in the EXCLUDED_LABELS list. @@ -72,7 +69,7 @@ function getExcludedLabel(labels) { /** * 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 +93,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 +105,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}`; } From 0e3280d42ceca6dd9a3145bf51571a3201c53db4 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Mon, 17 Aug 2026 04:38:05 +0100 Subject: [PATCH 07/13] style: re-ordered functions in the check-changelog-exclusions CI script for readability --- .../scripts/check-changelog-exclusions.mjs | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs index fcb6623..1c43c38 100644 --- a/.github/scripts/check-changelog-exclusions.mjs +++ b/.github/scripts/check-changelog-exclusions.mjs @@ -42,30 +42,6 @@ const ALL_COMMIT_TYPES = [...INCLUDED_TYPES, ...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; -} - /** * Checks if a PR should be excluded from the changelog */ @@ -128,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; +} From f45aa03d13c9b643d9cd6825dc74fc212a954671 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 00:49:22 +0100 Subject: [PATCH 08/13] ci: linkify issue/pr reference numbers with proper markdown links. - Added `linkifyReferences` function to orchestrate all other intermediate functions in order to find issue/pr references, resolve the correct GitHub URLs and linkify them into proper markdown links in the new changelog section. Also added its function call to the `formatPRDescription` function. - Added `findBareReferences` function to find all bare issue or pr references that aren't already linked within specified text, and collect the unique numbers. - Added `findClosingKeywordReferences` utils function in the new utils script to find all bare references that are preceded with closing keywords like close(s/d), fix(es/ed), resolve(s/d), and collect the unique numbers. - Added `resolveClosingKeywordReferenceUrl` function to resolve the closing keyword issue reference URL. It only needs to construct the URL from the context and reference number without an API call since closing keywords always references issues. - Added `resolveBareReferenceUrl` function to resolve the bare reference URL, using the GitHub REST API to lookup the reference number and determine whether it's an issue or a pull request, and returns the correct URL, or an empty string if errors occurred. - Updated the changelog CI permissions to include reading issues. - Updated the "Sparse checkout exclusion script" step in the CI to also checkout the utils script. - Updated the "Copy changelog script" step in the CI to also copy the utils script to a temp file so it can be imported properly in the temp update-changelog file. --- .github/scripts/update-changelog.mjs | 149 ++++++++++++++++++++++++++- .github/scripts/utils.mjs | 24 +++++ .github/workflows/changelog-ci.yml | 6 +- 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/utils.mjs diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index 94ae6ff..c2ae7a9 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -8,6 +8,7 @@ */ import {readFileSync, writeFileSync} from "fs"; +import * as utils from "./utils.mjs"; /** * Maps conventional commit types to changelog sections @@ -288,12 +289,12 @@ async function formatPRDescription(prBody, context, github) { return ""; } - // Convert markdown headings to bold text - const withoutHeadings = prBody.replace(/^#{1,6}\s+(.+)$/gm, "**$1**"); + // Convert markdown headings to bold text, and linkify bare issue/PR references + const formatted = await linkifyReferences(prBody.replace(/^#{1,6}\s+(.+)$/gm, "**$1**"), context, github); // 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 + const indented = formatted .split("\n") .map((line) => (line ? `${DESCRIPTION_INDENT}${line}` : "")) .join("\n"); @@ -304,6 +305,148 @@ async function formatPRDescription(prBody, context, github) { return `\n\n${indented.replace(/^\n+/, "")}`; } +/** + * Converts bare issue/PR #NNN references in text into markdown links, since GitHub only + * auto-links these in rendered comments/PR descriptions and never in the repo files. + * + * @link https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls#issues-and-pull-requests + * + * @param {string} text Text to linkify + * @param {import('@actions/github-script').AsyncFunctionArguments["context"]} context GitHub Actions context + * @param {import('@actions/github-script').AsyncFunctionArguments["github"]} github Octokit instance + * @returns {Promise} Text with #NNN references converted to markdown links + */ +async function linkifyReferences(text, context, github) { + // Find all bare #NNN references first to ensure there are some references to resolve. + const refNumbers = findBareReferences(text); + + // If there are no references to linkify, return the original text early + // to avoid unnecessary API calls. + if (refNumbers.size === 0) return text; + + // Find all closing keyword references to issues. + const closingNumbers = utils.findClosingKeywordReferences(text); + + // Remove any closing keyword references from the bare reference set. + for (const number of refNumbers) { + if (closingNumbers.has(number)) { + refNumbers.delete(number); + } + } + + // Store the updated text with links in a variable to avoid + // mutating the original text during iteration. + let textWithLinks = text; + + // For each closing keyword reference number... + for (const number of closingNumbers) { + // Resolve the reference to its real issue URL. + const link = resolveClosingKeywordReferenceUrl(number, context); + + // Replace all occurrences of the bare reference with the markdown link. + // (All references of the number are replaced, not just the closing keyword references.) + textWithLinks = textWithLinks.replace(new RegExp(`(?} Set of bare reference numbers + */ +function findBareReferences(text) { + // Collect the bare reference numbers in a Set to ensure it only captures unique numbers. + const refNumbers = new Set(); + let match; + + // The regex matches bare #NNN references. The (?!\]) negative lookahead ensures + // it doesn't match references that are already linked (e.g., [#123](...)). + const regex = /(?} The resolved GitHub URL or an empty string if it couldn't be resolved. + */ +async function resolveBareReferenceUrl(number, context, github) { + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Attempt to fetch the issue/PR data from GitHub API, using the reference number. + try { + const {data} = await github.rest.issues.get({ + owner, + repo, + issue_number: Number(number), + }); + + // If the data contains a pull_request field, it's a PR. + if (data.pull_request) { + // Return the PR URL + return data.pull_request.html_url; + } + // Otherwise, it's an issue. + else { + // Return the issue URL. + return data.html_url; + } + } catch (error) { + // Catches any API errors and non-2xx status codes (404, 301, 410, etc.) + // as well as network failures. + + console.log(`Could not resolve #${number}, leaving as-is. Error: ${error.message}`); + // Couldn't resolve the reference so just return an empty string. + return ""; + } +} + /** * Adds a PR entry to the appropriate section within Unreleased * diff --git a/.github/scripts/utils.mjs b/.github/scripts/utils.mjs new file mode 100644 index 0000000..e747f91 --- /dev/null +++ b/.github/scripts/utils.mjs @@ -0,0 +1,24 @@ +/** + * Finds all closing keyword references from a given text. These are always issues. + * Already linked references are ignored, as they don't need to be linkified. + * E.g., "Closes #12", "Fixes #45", "Resolves #77" will all be matched, + * but "[#13](...)" will not be matched. + * + * @param {string} text Text to search for closing keyword references + * @returns {Set} Set of referenced issue numbers + */ +function findClosingKeywordReferences(text) { + // Collect the bare reference numbers in a Set to ensure it only captures unique numbers. + const closingNumbers = new Set(); + let match; + + // Regex to match issue-closing keyword references. The (?!\]) negative lookahead ensures + // it doesn't match references that are already linked (e.g., [#123](...)). + const regex = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)\b(?!\])/gi; + + while ((match = regex.exec(text)) !== null) { + closingNumbers.add(match[1]); + } + + return closingNumbers; +} diff --git a/.github/workflows/changelog-ci.yml b/.github/workflows/changelog-ci.yml index 42cf4a1..d920b94 100644 --- a/.github/workflows/changelog-ci.yml +++ b/.github/workflows/changelog-ci.yml @@ -15,6 +15,7 @@ permissions: contents: write pull-requests: write actions: read + issues: read env: CHANGELOG_BASE_BRANCH: master @@ -32,6 +33,7 @@ jobs: sparse-checkout: | .github/scripts/check-changelog-exclusions.mjs .github/scripts/update-changelog.mjs + .github/scripts/utils.mjs sparse-checkout-cone-mode: false - name: Resolve PR data @@ -139,7 +141,9 @@ jobs: - name: Copy changelog script if: steps.check-exclusions.outputs.should-skip == 'false' - run: cp "${{ github.workspace }}/.github/scripts/update-changelog.mjs" /tmp/update-changelog.mjs + run: | + cp "${{ github.workspace }}/.github/scripts/update-changelog.mjs" /tmp/update-changelog.mjs + cp "${{ github.workspace }}/.github/scripts/utils.mjs" /tmp/utils.mjs - name: Checkout changelog branch if: steps.check-exclusions.outputs.should-skip == 'false' From a5c93dbfe4ff678faf452f0b01d81ceb6ca58efd Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 03:02:49 +0100 Subject: [PATCH 09/13] refactor: cross-file `const` variables moving them to the utils script. - Moved `TYPE_TO_SECTION` and `INCLUDED_TYPES` const variables from the update-changelog script to the utils script for better organisation of cross-file variables. Updated the references in the update-changelog script to use the `utils` namespace import. - Changed the `update-changelog` import to `utils` import in the check-changelog-exclusions script. - Updated the "Sparse checkout exclusion script" step name to "Sparse checkout scripts for PR exclusion checks" in the changelog CI so that it doesn't sound like it's excluding the specified files. - Removed the update-changelog script from the sparse checkout step in the changelog CI. --- .../scripts/check-changelog-exclusions.mjs | 2 +- .github/scripts/update-changelog.mjs | 24 ++----------------- .github/scripts/utils.mjs | 21 ++++++++++++++++ .github/workflows/changelog-ci.yml | 3 +-- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs index 1c43c38..bc329bc 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 "./utils.mjs"; /** * Labels that should exclude PRs from the changelog diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index c2ae7a9..0ae11e3 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -10,21 +10,6 @@ import {readFileSync, writeFileSync} from "fs"; import * as utils from "./utils.mjs"; -/** - * Maps conventional commit types to changelog sections - */ -const TYPE_TO_SECTION = { - feat: "Added", - fix: "Fixed", - refactor: "Changed", - perf: "Changed", - revert: "Changed", - remove: "Removed", - security: "Security", - change: "Changed", - deprecate: "Deprecated", -}; - /** * Maps commit types to custom display prefixes in changelog entries. * When a type is listed here, its capitalised name is used as the prefix @@ -54,16 +39,11 @@ const PREFIX_TO_LEADING_VERB_REGEX = { */ const DESCRIPTION_INDENT = " "; -/** - * Array of included commit types derived from the keys of TYPE_TO_SECTION object - */ -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(`^(${utils.INCLUDED_TYPES.join("|")})(\\(.+?\\))?!?:\\s*`, "i"); /** * Main function to update the changelog @@ -91,7 +71,7 @@ export default async function updateChangelog({pr, core, context, github}) { return; } - const section = TYPE_TO_SECTION[type]; + const section = utils.TYPE_TO_SECTION[type]; console.log(`📂 Type: ${type} → Section: ${section}`); // Read current changelog diff --git a/.github/scripts/utils.mjs b/.github/scripts/utils.mjs index e747f91..cf06dd6 100644 --- a/.github/scripts/utils.mjs +++ b/.github/scripts/utils.mjs @@ -1,3 +1,24 @@ +/** + * Maps conventional commit types to changelog sections + */ +export const TYPE_TO_SECTION = { + feat: "Added", + fix: "Fixed", + refactor: "Changed", + perf: "Changed", + revert: "Changed", + remove: "Removed", + security: "Security", + change: "Changed", + deprecate: "Deprecated", +}; + +/** + * Array of included commit types derived from the keys of TYPE_TO_SECTION object + * @see {@link TYPE_TO_SECTION} + */ +export const INCLUDED_TYPES = Object.keys(TYPE_TO_SECTION); + /** * Finds all closing keyword references from a given text. These are always issues. * Already linked references are ignored, as they don't need to be linkified. diff --git a/.github/workflows/changelog-ci.yml b/.github/workflows/changelog-ci.yml index d920b94..8f2be74 100644 --- a/.github/workflows/changelog-ci.yml +++ b/.github/workflows/changelog-ci.yml @@ -27,12 +27,11 @@ jobs: runs-on: ubuntu-latest steps: - - name: Sparse checkout exclusion script + - name: Sparse checkout scripts for PR exclusion checks uses: actions/checkout@v4 with: sparse-checkout: | .github/scripts/check-changelog-exclusions.mjs - .github/scripts/update-changelog.mjs .github/scripts/utils.mjs sparse-checkout-cone-mode: false From f522316a31357619ed66543ee7c2eab81a564bba Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 03:35:11 +0100 Subject: [PATCH 10/13] refactor: global variables to be local variables in `checkExclusions` function - Moved the `ALL_COMMIT_TYPES` and `typeRegex` global variables to be local variables in the `checkExclusions` function of the `check-changelog-exclusions` script. This is because they're not used in any other function so they don't need to be global variables. Also made the `ALL_COMMIT_TYPES` all lowercase. All uppercase should be kept for global variables/constants. --- .../scripts/check-changelog-exclusions.mjs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs index bc329bc..22b6dc2 100644 --- a/.github/scripts/check-changelog-exclusions.mjs +++ b/.github/scripts/check-changelog-exclusions.mjs @@ -30,18 +30,6 @@ const EXCLUDED_TYPES = [ "test", ]; -/** - * All valid commit types (included + excluded) - * Included types are derived from TYPE_TO_SECTION - */ -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 a PR should be excluded from the changelog */ @@ -64,6 +52,17 @@ export default async function checkExclusions({pr, core}) { } // Check for conventional commit type else { + /** + * All valid commit types (included + excluded) + * Included types are derived from TYPE_TO_SECTION + */ + 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"); + // Match the PR title against the regex to extract the commit type. const match = prTitle.match(typeRegex); From 037eeb77b3604f5bc3a72169dc4701a16297a1aa Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 05:28:43 +0100 Subject: [PATCH 11/13] ci: support auto commenting on closed linked issues after release - Refactored `findClosingKeywordReferences` utils function to optionally match already linked references via Markdown links with a new `matchMarkdownLinks` param. - Added new step in the publish CI to comment on closed issues that are referenced in the changelog of the newly released version. This step takes place after the publishing step, and uses the new script file. - Added new `comment-on-linked-issues` script file for the functionality of finding any reference numbers in the changelog and auto comment on the issues that the new release has been published. The script has: - `commentOnLinkedIssues` main function to retrieve the closing issues from the changelog version entry, and add comment on them to let the issue author know that the resolution of the issues has been released in a new version. This uses the `findClosingKeywordReferences` utils function to retrieve the issue-closing keyword references using the new `matchMarkdownLinks` param as `true`. The script also uses the Octokit GitHub API to create the comment. - `extractChangelogEntry` function to extract the changelog section for a specified version. - `commentExists` function to check if a comment already exists for the specific version on the issue. This uses the Octokit GitHub API to paginate through the issue's comments and filters the comments that match the criteria. - Updated permissions to allow writing issues in the publish CI. --- .github/scripts/comment-on-linked-issues.mjs | 119 +++++++++++++++++++ .github/scripts/utils.mjs | 34 ++++-- .github/workflows/publish-extension.yml | 14 +++ 3 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/comment-on-linked-issues.mjs diff --git a/.github/scripts/comment-on-linked-issues.mjs b/.github/scripts/comment-on-linked-issues.mjs new file mode 100644 index 0000000..850406f --- /dev/null +++ b/.github/scripts/comment-on-linked-issues.mjs @@ -0,0 +1,119 @@ +/** + * Comments on closed issues referenced (directly, or indirectly via a linked PR) + * in a version's changelog entry. Used by the publish workflow after a release deploy. + */ + +import {readFileSync} from "fs"; +import * as utils from "./utils.mjs"; + +/** + * Main function to comment on closed issues referenced in a version's changelog entry. + * + * @param {object} params + * @param {import('@actions/github-script').AsyncFunctionArguments["github"]} params.github Octokit instance + * @param {import('@actions/github-script').AsyncFunctionArguments["context"]} params.context Workflow run context + * @param {string} params.version Released version (without leading "v") + */ +export default async function commentOnLinkedIssues({github, context, version}) { + const owner = context.repo.owner; + const repo = context.repo.repo; + const releaseUrl = context.payload.release.html_url; + + // Read the changelog. + const changelog = readFileSync("CHANGELOG.md", "utf8"); + // Extract the changelog entry for the released version. + const entry = extractChangelogEntry(changelog, version); + + // Find all closing keyword issue references. + const issuesToComment = utils.findClosingKeywordReferences(entry, true); + + console.log("Issues to comment on:", [...issuesToComment]); + + // Loop through each issue number and comment on it with a message about the release. + for (const issueNumber of issuesToComment) { + const comment = `🚀 This issue has been resolved and released in [v${version}](${releaseUrl})! Please update to v${version}.`; + + // If a comment already exists for the version, skip commenting on this issue. + if (await commentExists(github, context, issueNumber, `${comment}`)) { + console.log(`Comment already exists on issue #${issueNumber}, skipping.`); + continue; + } + + // Create a comment on the issue. + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: comment, + }); + console.log(`Commented on issue #${issueNumber}`); + } +} + +/** + * Extracts the changelog section for a given version + * + * @param {string} changelog Full CHANGELOG.md content + * @param {string} version Version to find (without leading "v") + * @returns {string} The changelog entry text for that version + */ +function extractChangelogEntry(changelog, version) { + // Find the index of the version heading in the changelog. + const versionHeadingIndex = changelog.indexOf(`## [${version}]`); + + // If the version heading is not found, throw an error. + if (versionHeadingIndex === -1) { + throw new Error(`Could not find CHANGELOG.md entry for version ${version}`); + } + + // Find the index of the next version heading (or end of file) + const nextHeadingIndex = changelog.indexOf("\n## [", versionHeadingIndex + 1); + + // Return the full version entry section in the changelog. The section starts with the + // version heading and continues until the next version heading or the end of the file. + return nextHeadingIndex === -1 ? changelog.slice(versionHeadingIndex) : changelog.slice(versionHeadingIndex, nextHeadingIndex); +} + +/** + * Checks if a comment with the given substring already exists on the issue. + * + * @param {import('@actions/github-script').AsyncFunctionArguments["github"]} github Octokit instance + * @param {import('@actions/github-script').AsyncFunctionArguments["context"]} context GitHub Actions context + * @param {number} issueNumber Issue number + * @param {string} substring Substring to check for in the comment body + * @returns {boolean} True if a comment containing the substring exists, false otherwise + */ +async function commentExists(github, context, issueNumber, substring) { + // Paginate through all existing comments on the issue and + // check if any comment contains the substring. + const filteredArray = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }, + // A callback function is called for each page of comments and returns a + // filtered array of comments that match the criteria. + (response, done) => { + // Find the comment containing the substring in the body. + const foundComment = response.data.find((comment) => comment.body.includes(substring)); + + // If a comment is found, we can stop paginating and return the comment. + if (foundComment) { + done(); + return foundComment; + } + + // Otherwise, continue paginating through the comments until we find a match or + // reach the end of the list. Return an empty array if no comment is found. + return []; + }, + ); + + console.log("Comments found:", filteredArray); + + // If the filtered array contains any comments that + // match the substring, return true, false otherwise. + return filteredArray.length > 0 ? true : false; +} diff --git a/.github/scripts/utils.mjs b/.github/scripts/utils.mjs index cf06dd6..7c19b70 100644 --- a/.github/scripts/utils.mjs +++ b/.github/scripts/utils.mjs @@ -21,22 +21,42 @@ export const INCLUDED_TYPES = Object.keys(TYPE_TO_SECTION); /** * Finds all closing keyword references from a given text. These are always issues. - * Already linked references are ignored, as they don't need to be linkified. - * E.g., "Closes #12", "Fixes #45", "Resolves #77" will all be matched, - * but "[#13](...)" will not be matched. + * + * If `matchMarkdownLinks` is `true`, then it will match already linked references via + * Markdown links e.g., `[#123](...)`, otherwise it will ignore them. + * + * Example: + * - `matchMarkdownLinks=false`: `"Closes #12"` will be matched, but `"Closes [#13](...)"` + * will not be matched. + * - `matchMarkdownLinks=true`: `"Closes [#13](...)"` will be matched, but `"Closes #12"` + * will not be matched. * * @param {string} text Text to search for closing keyword references + * + * @param {boolean} [matchMarkdownLinks=false] Whether to match already linked references via Markdown links e.g., `[#123](...)`. If `false` (default), it won't match Markdown links. * @returns {Set} Set of referenced issue numbers */ -function findClosingKeywordReferences(text) { +export function findClosingKeywordReferences(text, matchMarkdownLinks = false) { // Collect the bare reference numbers in a Set to ensure it only captures unique numbers. const closingNumbers = new Set(); let match; + // 1st part of the regex to match issue-closing keyword references. + let regex = "\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*"; - // Regex to match issue-closing keyword references. The (?!\]) negative lookahead ensures - // it doesn't match references that are already linked (e.g., [#123](...)). - const regex = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)\b(?!\])/gi; + // If matchMarkdownLinks is false, DON'T match already linked references. + if (!matchMarkdownLinks) { + // The (?!\]) negative lookahead ensures it DOESN'T match references + // that are already linked (e.g., closes #12). + regex = new RegExp(regex + "#(\\d+)\\b(?!\\])", "gi"); + } + // Otherwise, match references that are already linked. + else { + // Matches already linked references (e.g., closes [#12](...)). + regex = new RegExp(regex + "\\[#(\\d+)\\]\\(.*?\\)", "gi"); + } + // While there are matching issue-closing keyword references in the text, + // add the reference numbers to the Set. while ((match = regex.exec(text)) !== null) { closingNumbers.add(match[1]); } diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml index 4b788cd..b7ea86f 100644 --- a/.github/workflows/publish-extension.yml +++ b/.github/workflows/publish-extension.yml @@ -12,6 +12,7 @@ permissions: contents: write pull-requests: write actions: read + issues: write name: Publish Extension to VS Code Marketplace and Open VSX Registry jobs: @@ -333,3 +334,16 @@ jobs: - name: Report Success run: | echo "✅ Successfully published to both marketplaces" + + - name: Comment on linked issues + uses: actions/github-script@v7 + with: + script: | + const { default: commentOnLinkedIssues } = await import('${{ github.workspace }}/.github/scripts/comment-on-linked-issues.mjs'); + await commentOnLinkedIssues({ + github, + context, + version: '${{ needs.validate-release-version.outputs.version }}', + }); + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From b22917f145f11fc3ea7b8ecaa4b8c32cbe0a22bb Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 05:51:29 +0100 Subject: [PATCH 12/13] remove: test suite and dependencies. The test suite is not used, and the tests are still example files anyway. - Removed `@types/mocha` and `mocha` dependencies. - Removed the `test` script from package.json. - Removed the `Run Extension Tests` configuration from the launch.json. - Removed the test files: `extension.test.ts` and `index.ts`. --- .vscode/launch.json | 16 ---------------- package.json | 5 +---- test/extension.test.ts | 22 ---------------------- test/index.ts | 22 ---------------------- 4 files changed, 1 insertion(+), 64 deletions(-) delete mode 100644 test/extension.test.ts delete mode 100644 test/index.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index bb391ca..ee40cea 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,22 +16,6 @@ "${workspaceFolder}/out/**/*.js" ], "preLaunchTask": "npm: watch" - }, - { - "name": "Run Extension Tests", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "--disable-extensions", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test" - ], - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "npm: watch" } ] } diff --git a/package.json b/package.json index b0e82fa..6f3c2d7 100644 --- a/package.json +++ b/package.json @@ -130,14 +130,11 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "test": "node ./out/test/extension.test.js" + "watch": "tsc -watch -p ./" }, "devDependencies": { - "@types/mocha": "^10.0.9", "@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" diff --git a/test/extension.test.ts b/test/extension.test.ts deleted file mode 100644 index 5c4a4da..0000000 --- a/test/extension.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// -// Note: This example test is leveraging the Mocha test framework. -// Please refer to their documentation on https://mochajs.org/ for help. -// - -// The module 'assert' provides assertion methods from node -import * as assert from 'assert'; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from 'vscode'; -import * as myExtension from '../src/extension'; - -// Defines a Mocha test suite to group tests of similar kind together -suite("Extension Tests", () => { - - // Defines a Mocha unit test - test("Something 1", () => { - assert.equal(-1, [1, 2, 3].indexOf(5)); - assert.equal(-1, [1, 2, 3].indexOf(0)); - }); -}); \ No newline at end of file diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index 50bae45..0000000 --- a/test/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -// -// PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING -// -// This file is providing the test runner to use when running extension tests. -// By default the test runner in use is Mocha based. -// -// You can provide your own test runner if you want to override it by exporting -// a function run(testRoot: string, clb: (error:Error) => void) that the extension -// host can call to run the tests. The test runner is expected to use console.log -// to report the results back to the caller. When the tests are finished, return -// a possible error to the callback or null if none. - -var testRunner = require('vscode/lib/testrunner'); - -// You can directly control Mocha options by uncommenting the following lines -// See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info -testRunner.configure({ - ui: 'tdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) - useColors: true // colored output from test results -}); - -module.exports = testRunner; \ No newline at end of file From 5a67aae55e6403f4ca794dc6d81a75d8c4ae43a5 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 05:51:29 +0100 Subject: [PATCH 13/13] remove: test suite and dependencies. The test suite is not used, and the tests are still example files anyway. - Removed `@types/mocha` and `mocha` dependencies. - Removed the `test` script from package.json. - Removed the `Run Extension Tests` configuration from the launch.json. - Removed the test files: `extension.test.ts` and `index.ts`. --- .vscode/launch.json | 16 ---------------- package.json | 5 +---- test/extension.test.ts | 22 ---------------------- test/index.ts | 22 ---------------------- 4 files changed, 1 insertion(+), 64 deletions(-) delete mode 100644 test/extension.test.ts delete mode 100644 test/index.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index bb391ca..ee40cea 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,22 +16,6 @@ "${workspaceFolder}/out/**/*.js" ], "preLaunchTask": "npm: watch" - }, - { - "name": "Run Extension Tests", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "--disable-extensions", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test" - ], - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "npm: watch" } ] } diff --git a/package.json b/package.json index b0e82fa..6f3c2d7 100644 --- a/package.json +++ b/package.json @@ -130,14 +130,11 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "test": "node ./out/test/extension.test.js" + "watch": "tsc -watch -p ./" }, "devDependencies": { - "@types/mocha": "^10.0.9", "@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" diff --git a/test/extension.test.ts b/test/extension.test.ts deleted file mode 100644 index 5c4a4da..0000000 --- a/test/extension.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// -// Note: This example test is leveraging the Mocha test framework. -// Please refer to their documentation on https://mochajs.org/ for help. -// - -// The module 'assert' provides assertion methods from node -import * as assert from 'assert'; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from 'vscode'; -import * as myExtension from '../src/extension'; - -// Defines a Mocha test suite to group tests of similar kind together -suite("Extension Tests", () => { - - // Defines a Mocha unit test - test("Something 1", () => { - assert.equal(-1, [1, 2, 3].indexOf(5)); - assert.equal(-1, [1, 2, 3].indexOf(0)); - }); -}); \ No newline at end of file diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index 50bae45..0000000 --- a/test/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -// -// PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING -// -// This file is providing the test runner to use when running extension tests. -// By default the test runner in use is Mocha based. -// -// You can provide your own test runner if you want to override it by exporting -// a function run(testRoot: string, clb: (error:Error) => void) that the extension -// host can call to run the tests. The test runner is expected to use console.log -// to report the results back to the caller. When the tests are finished, return -// a possible error to the callback or null if none. - -var testRunner = require('vscode/lib/testrunner'); - -// You can directly control Mocha options by uncommenting the following lines -// See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info -testRunner.configure({ - ui: 'tdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) - useColors: true // colored output from test results -}); - -module.exports = testRunner; \ No newline at end of file