From f45aa03d13c9b643d9cd6825dc74fc212a954671 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 19 Aug 2026 00:49:22 +0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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);