Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions .github/scripts/check-changelog-exclusions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand All @@ -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);

Expand Down
173 changes: 148 additions & 25 deletions .github/scripts/update-changelog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand All @@ -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<string>} 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(`(?<!\\w)#(${number})\\b(?!\\])`, "g"), `[#${number}](${link})`);
}

// For each bare reference number...
for (const number of refNumbers) {
// Resolve the reference to its real issue/PR URL.
const link = await resolveBareReferenceUrl(number, context, github);

// If the link couldn't be resolved, skip replacing it.
if (!link) {
continue;
}

// Replace all occurrences of the bare reference with the markdown link.
textWithLinks = textWithLinks.replace(new RegExp(`(?<!\\w)#(${number})\\b(?!\\])`, "g"), `[#${number}](${link})`);
}

// Return the updated text.
return textWithLinks;
}

/**
* Finds all bare #NNN references to issues or PRs in the given text.
* Already linked references are ignored, as they don't need to be linkified.
* E.g., "#12" will be matched, but "[#13](...)" will not be matched.
*
* @param {string} text Text to search for bare references
* @returns {Set<string>} 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 = /(?<!\w)#(\d+)\b(?!\])/g;
while ((match = regex.exec(text)) !== null) {
refNumbers.add(match[1]);
}

return refNumbers;
}

/**
* Resolves a closing keyword reference to its real URL.
* Closing keywords always refer to issues.
*
* @param {string} number Referenced number
* @param {import('@actions/github-script').AsyncFunctionArguments["context"]} context GitHub Actions context
* @returns {string} The GitHub URL for the issue
*/
function resolveClosingKeywordReferenceUrl(number, context) {
const owner = context.repo.owner;
const repo = context.repo.repo;

return `https://github.com/${owner}/${repo}/issues/${number}`;
}

/**
* Resolves a bare reference number to its real URL.
* Bare references can point to either issues or pull requests,
* so it requires an API call to determine the correct type.
* If the reference cannot be resolved, an empty string is returned.
*
* @param {string} number Referenced number
* @param {import('@actions/github-script').AsyncFunctionArguments["context"]} context GitHub Actions context
* @param {import('@actions/github-script').AsyncFunctionArguments["github"]} github Octokit instance
* @returns {Promise<string>} 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
*
Expand Down
45 changes: 45 additions & 0 deletions .github/scripts/utils.mjs
Original file line number Diff line number Diff line change
@@ -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<string>} 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;
}
9 changes: 6 additions & 3 deletions .github/workflows/changelog-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ permissions:
contents: write
pull-requests: write
actions: read
issues: read

env:
CHANGELOG_BASE_BRANCH: master
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down
Loading