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 }}