diff --git a/.github/scripts/check-changelog-exclusions.mjs b/.github/scripts/check-changelog-exclusions.mjs index 1c43c38..22b6dc2 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 @@ -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); diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index 94ae6ff..0ae11e3 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -8,21 +8,7 @@ */ import {readFileSync, writeFileSync} from "fs"; - -/** - * 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", -}; +import * as utils from "./utils.mjs"; /** * Maps commit types to custom display prefixes in changelog entries. @@ -53,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 @@ -90,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 @@ -288,12 +269,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 +285,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..cf06dd6 --- /dev/null +++ b/.github/scripts/utils.mjs @@ -0,0 +1,45 @@ +/** + * 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. + * 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..8f2be74 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 @@ -26,12 +27,12 @@ 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 - name: Resolve PR data @@ -139,7 +140,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'