diff --git a/.github/actions/release-commenter/action.yml b/.github/actions/release-commenter/action.yml
new file mode 100644
index 00000000..629f6171
--- /dev/null
+++ b/.github/actions/release-commenter/action.yml
@@ -0,0 +1,107 @@
+name: "Release PR Commenter"
+description: >
+ Posts a comment on every pull request included in a release, linking back to the
+ release page. Compares the current release with the previous one to find all
+ associated PRs by walking the commit graph.
+
+runs:
+ using: composite
+ steps:
+ - name: Comment on released PRs
+ uses: actions/github-script@v7
+ with:
+ retries: 3
+ script: |
+ // Returns true only for the first occurrence of value in array (deduplication helper)
+ function onlyUnique(value, index, array) {
+ return array.indexOf(value) === index;
+ }
+
+ // Posts a release notification comment on a pull request
+ async function createCommentForPR(pr_id, release) {
+ const messageId = ``;
+ const message = `
+ ${messageId}
+ These changes were released in [${release.name}](${release.html_url}).
+ `;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr_id,
+ body: message,
+ });
+ }
+
+ // Fetch the current release from the event payload
+ const releaseId = context.payload.release.id;
+
+ const currentReleaseResponse = await github.rest.repos.getRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ });
+
+ const currentRelease = currentReleaseResponse.data;
+ const currentTag = currentRelease.tag_name;
+
+ // Find the previous release so we can compare commits
+ const releases = await github.rest.repos.listReleases({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
+
+ let previousRelease = null;
+ let currentReleaseFound = false;
+
+ for (const release of releases.data) {
+ if (currentReleaseFound) {
+ previousRelease = release;
+ break;
+ } else if (release.tag_name === currentTag) {
+ currentReleaseFound = true;
+ }
+ }
+
+ if (previousRelease === null) {
+ core.info(`No previous release found for ${currentTag}`);
+ return;
+ }
+
+ // Compare commits between current and previous release to find associated PRs
+ const commitsResponse = await github.rest.repos.compareCommits({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ base: previousRelease.tag_name,
+ head: currentRelease.tag_name,
+ });
+
+ const pull_requests = [];
+
+ for (const commit of commitsResponse.data.commits) {
+ const query = `
+ {
+ resource(url: "${context.payload.repository.html_url}/commit/${commit.sha}") {
+ ... on Commit {
+ messageHeadlineHTML
+ messageBodyHTML
+ associatedPullRequests(first: 10) {
+ pageInfo { hasNextPage }
+ edges { node { number } }
+ }
+ }
+ }
+ }
+ `;
+
+ const response = await github.graphql(query);
+
+ for (const edge of response.resource.associatedPullRequests.edges) {
+ pull_requests.push(edge.node.number);
+ }
+ }
+
+ // Post a release comment on each unique PR
+ for (const id of pull_requests.filter(onlyUnique)) {
+ await createCommentForPR(id, currentRelease);
+ }
diff --git a/.github/actions/substitute-llm-authors/action.yml b/.github/actions/substitute-llm-authors/action.yml
new file mode 100644
index 00000000..4114a96a
--- /dev/null
+++ b/.github/actions/substitute-llm-authors/action.yml
@@ -0,0 +1,142 @@
+name: "Substitute LLM Authors in Release Notes"
+description: >
+ Replaces LLM/AI bot author mentions in release notes with the PR's human assignee.
+ When a PR is authored by an AI assistant (e.g. GitHub Copilot), this action substitutes
+ the bot username with the human assignee so they receive proper credit in the changelog.
+ Logic mirrors the unit-tested module at release-author-substitutor.js.
+
+inputs:
+ token:
+ description: "GitHub token with repo read/write access for release and PR APIs"
+ required: true
+ release-id:
+ description: "The numeric ID of the release to update (from the release-drafter output)"
+ required: true
+ llm-users:
+ description: >
+ JSON array of LLM/AI bot GitHub usernames whose PR authorship should be replaced
+ with the PR's human assignee in the release notes.
+ Example: '["github-copilot[bot]","devin-ai-integration[bot]"]'
+ Glob patterns with '*' are supported for flexible matching.
+ Defaults to a built-in list that includes GitHub Copilot and other common AI assistants.
+ required: false
+ default: ''
+
+runs:
+ using: composite
+ steps:
+ - name: Substitute LLM authors
+ uses: actions/github-script@v7
+ env:
+ LLM_USERS: ${{ inputs.llm-users }}
+ RELEASE_ID: ${{ inputs.release-id }}
+ with:
+ github-token: ${{ inputs.token }}
+ retries: 3
+ script: |
+ // Default list of LLM/AI assistant usernames.
+ // Logic mirrors the unit-tested module at release-author-substitutor.js.
+ const DEFAULT_LLM_USERS = [
+ 'github-copilot[bot]',
+ 'copilot',
+ 'devin-ai-integration[bot]',
+ 'amazon-q[bot]',
+ 'codeium[bot]',
+ ];
+
+ const llmUsersInput = process.env.LLM_USERS;
+ const llmUsers = (llmUsersInput && llmUsersInput.trim())
+ ? JSON.parse(llmUsersInput)
+ : DEFAULT_LLM_USERS;
+
+ function isLLMUser(username) {
+ const norm = username.toLowerCase();
+ return llmUsers.some(llmUser => {
+ const nu = llmUser.toLowerCase();
+ if (nu.includes('*')) {
+ const regexStr = nu.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
+ return new RegExp(`^${regexStr}$`).test(norm);
+ }
+ return norm === nu;
+ });
+ }
+
+ function extractPRReferences(body) {
+ const prPattern = /@([a-zA-Z0-9_.\-[\]]+)\s+\(#(\d+)\)/g;
+ const matches = [];
+ let match;
+ // fullMatch is omitted here (unused); see release-author-substitutor.js for full shape
+ while ((match = prPattern.exec(body)) !== null) {
+ matches.push({ author: match[1], prNumber: parseInt(match[2], 10) });
+ }
+ return matches;
+ }
+
+ function substituteLLMAuthors(body, prSubstitutions) {
+ let updatedBody = body;
+ for (const { prNumber, oldAuthor, newAuthor } of prSubstitutions) {
+ const escapedAuthor = oldAuthor.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = new RegExp(`@${escapedAuthor}(\\s+\\(#${prNumber}\\))`, 'g');
+ updatedBody = updatedBody.replace(pattern, `@${newAuthor}$1`);
+ }
+ return updatedBody;
+ }
+
+ const releaseId = parseInt(process.env.RELEASE_ID, 10);
+
+ const { data: release } = await github.rest.repos.getRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ });
+
+ const body = release.body || '';
+ const prRefs = extractPRReferences(body);
+ const llmPRRefs = prRefs.filter(ref => isLLMUser(ref.author));
+
+ if (llmPRRefs.length === 0) {
+ core.info('No LLM-authored PRs found in release notes');
+ return;
+ }
+
+ const prSubstitutions = [];
+
+ for (const prRef of llmPRRefs) {
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prRef.prNumber,
+ });
+
+ const humanAssignee = (pr.assignees || []).find(a => !isLLMUser(a.login));
+ if (humanAssignee) {
+ prSubstitutions.push({
+ prNumber: prRef.prNumber,
+ oldAuthor: prRef.author,
+ newAuthor: humanAssignee.login,
+ });
+ core.info(`PR #${prRef.prNumber}: substituting @${prRef.author} with @${humanAssignee.login}`);
+ } else {
+ core.info(`PR #${prRef.prNumber}: no human assignee found for @${prRef.author}, keeping original`);
+ }
+ } catch (error) {
+ core.warning(`Failed to get assignees for PR #${prRef.prNumber}: ${error.message}`);
+ }
+ }
+
+ if (prSubstitutions.length === 0) {
+ core.info('No substitutions needed');
+ return;
+ }
+
+ const updatedBody = substituteLLMAuthors(body, prSubstitutions);
+
+ await github.rest.repos.updateRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: releaseId,
+ body: updatedBody,
+ });
+
+ core.info(`Release notes updated: ${prSubstitutions.length} LLM author(s) replaced with human assignee(s)`);
diff --git a/.github/actions/substitute-llm-authors/release-author-substitutor.js b/.github/actions/substitute-llm-authors/release-author-substitutor.js
new file mode 100644
index 00000000..f1bb3aef
--- /dev/null
+++ b/.github/actions/substitute-llm-authors/release-author-substitutor.js
@@ -0,0 +1,97 @@
+'use strict';
+
+/**
+ * Default list of LLM/AI assistant usernames to detect in release notes.
+ * These will be replaced with the PR's human assignee when generating release notes.
+ * Supports exact match (case-insensitive) and glob patterns using '*'.
+ */
+const DEFAULT_LLM_USERS = [
+ 'github-copilot[bot]',
+ 'copilot',
+ 'devin-ai-integration[bot]',
+ 'amazon-q[bot]',
+ 'codeium[bot]',
+];
+
+/**
+ * Converts a glob pattern (with * wildcards) to a RegExp that matches the full string.
+ * @param {string} pattern - Glob pattern (case-insensitive, already lowercased)
+ * @returns {RegExp}
+ */
+function globToRegex(pattern) {
+ const regexStr = pattern
+ .split('*')
+ .map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
+ .join('.*');
+ return new RegExp(`^${regexStr}$`);
+}
+
+/**
+ * Checks whether a GitHub username matches an LLM/AI user pattern.
+ * Matching is case-insensitive. Supports exact matches and glob patterns using '*'.
+ *
+ * @param {string} username - GitHub username to check
+ * @param {string[]} [llmUsers] - List of LLM usernames or glob patterns.
+ * Defaults to DEFAULT_LLM_USERS when not provided.
+ * @returns {boolean} True if the username matches any entry in the LLM users list
+ */
+function isLLMUser(username, llmUsers) {
+ const users = llmUsers || DEFAULT_LLM_USERS;
+ const norm = username.toLowerCase();
+ return users.some(llmUser => {
+ const nu = llmUser.toLowerCase();
+ if (nu.includes('*')) {
+ return globToRegex(nu).test(norm);
+ }
+ return norm === nu;
+ });
+}
+
+/**
+ * Extracts PR author and number references from a release notes body.
+ * Matches patterns produced by the release-drafter change-template, e.g.:
+ * "@username (#123)"
+ *
+ * @param {string} body - Release notes body text
+ * @returns {Array<{author: string, prNumber: number, fullMatch: string}>}
+ */
+function extractPRReferences(body) {
+ const prPattern = /@([a-zA-Z0-9_.\-[\]]+)\s+\(#(\d+)\)/g;
+ const matches = [];
+ let match;
+ while ((match = prPattern.exec(body)) !== null) {
+ matches.push({
+ fullMatch: match[0],
+ author: match[1],
+ prNumber: parseInt(match[2], 10),
+ });
+ }
+ return matches;
+}
+
+/**
+ * Substitutes LLM bot authors with human assignees in release notes.
+ * Each substitution is targeted per-PR number so that multiple PRs from the
+ * same bot can be attributed to different human assignees.
+ *
+ * @param {string} body - Release notes body text
+ * @param {Array<{prNumber: number, oldAuthor: string, newAuthor: string}>} prSubstitutions
+ * @returns {string} Updated release notes body
+ */
+function substituteLLMAuthors(body, prSubstitutions) {
+ let updatedBody = body;
+ for (const { prNumber, oldAuthor, newAuthor } of prSubstitutions) {
+ const escapedAuthor = oldAuthor.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = new RegExp(`@${escapedAuthor}(\\s+\\(#${prNumber}\\))`, 'g');
+ updatedBody = updatedBody.replace(pattern, `@${newAuthor}$1`);
+ }
+ return updatedBody;
+}
+
+module.exports = {
+ DEFAULT_LLM_USERS,
+ globToRegex,
+ isLLMUser,
+ extractPRReferences,
+ substituteLLMAuthors,
+};
diff --git a/.github/workflows/shared-auto-release.yml b/.github/workflows/shared-auto-release.yml
index 9ec2a288..38835a46 100644
--- a/.github/workflows/shared-auto-release.yml
+++ b/.github/workflows/shared-auto-release.yml
@@ -12,6 +12,16 @@ on:
required: false
default: false
type: string
+ llm-users:
+ description: >
+ JSON array of LLM/AI bot GitHub usernames whose PR authorship should be replaced
+ with the PR's human assignee in the release notes.
+ Example: '["github-copilot[bot]","devin-ai-integration[bot]"]'
+ Glob patterns with '*' are supported for flexible matching.
+ Defaults to a built-in list that includes GitHub Copilot and other common AI assistants.
+ required: false
+ default: ''
+ type: string
runs-on:
description: "Overrides job runs-on setting (json-encoded list)"
type: string
@@ -128,6 +138,16 @@ jobs:
summary-enabled: ${{ inputs.summary-enabled }}
config-name: ${{ steps.context.outputs.config }}
+ # When a PR was authored by an LLM/AI assistant, replace the bot username with the
+ # PR's human assignee in the release notes so humans get proper credit.
+ - name: Substitute LLM authors in release notes
+ if: ${{ steps.drafter.outputs.id != '' }}
+ uses: ./.github/actions/substitute-llm-authors
+ with:
+ token: ${{ steps.github-app.outputs.token }}
+ release-id: ${{ steps.drafter.outputs.id }}
+ llm-users: ${{ inputs.llm-users }}
+
# - uses: actions/github-script@v7
# if: ${{ steps.context.outputs.config == 'auto-feature-release.yml' && steps.drafter.outputs.id != '' }}
# with:
diff --git a/.github/workflows/shared-release-branches.yml b/.github/workflows/shared-release-branches.yml
index c32185dd..052d1a96 100644
--- a/.github/workflows/shared-release-branches.yml
+++ b/.github/workflows/shared-release-branches.yml
@@ -48,131 +48,5 @@ jobs:
release-commenter:
runs-on: ${{ fromJSON(inputs.runs-on) }}
steps:
- - uses: actions/github-script@v7
- with:
- result-encoding: string
- retries: 3
- script: |
- // Function to check if a value is unique in an array
- function onlyUnique(value, index, array) {
- return array.indexOf(value) === index;
- }
-
- // Function to create or update a comment for a pull request (PR) associated with a release
- async function createCommentForPR(pr_id, release) {
- // Parameters for fetching comments related to the PR
- const parameters = {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr_id,
- per_page: 100,
- }
-
- // Constructing the message to be posted or updated as a comment
- const messageId = ``;
- const message = `
- ${messageId}
- These changes were released in [${release.name}](${release.html_url}).
- `;
-
- // Π‘reate a new comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr_id,
- body: message
- });
- }
-
- // Retrieving the ID of the current release
- release_id = context.payload.release.id;
-
- // Fetching details of the current release
- currentReleaseResponse = await github.rest.repos.getRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id,
- });
-
- currentRelease = currentReleaseResponse.data;
-
- // Extracting tag name and target branch from the current release
- currentTag = currentRelease.tag_name;
- currentBranch = currentRelease.target_commitish;
-
- // Listing all releases of the repository
- releases = await github.rest.repos.listReleases({
- owner: context.repo.owner,
- repo: context.repo.repo,
- });
-
- // Initializing variables for storing information about the previous release
- previousRelease = null;
- currentReleaseFound = false;
-
- // Iterating through releases to find the previous release relative to the current one
- for (release of releases.data) {
- if (currentReleaseFound) {
- previousRelease = release;
- break;
- } else if (release.tag_name == currentTag) {
- currentReleaseFound = true;
- }
- }
-
- // If no previous release is found, log a message and return
- if (previousRelease == null) {
- console.log(`No previous release found for ${currentTag}`);
- return;
- }
-
- // Comparing commits between the current and previous releases
- commitsResponse = await github.rest.repos.compareCommits({
- owner: context.repo.owner,
- repo: context.repo.repo,
- base: previousRelease.tag_name,
- head: currentRelease.tag_name,
- });
-
- commits = commitsResponse.data;
-
- // Initializing an array to store pull request numbers associated with the commits
- pull_requests = [];
-
- // Iterating through commits to find associated pull requests and extracting their numbers
- for (commit of commits.commits) {
- responseCommit = await github.rest.git.getCommit({
- owner: context.repo.owner,
- repo: context.repo.repo,
- commit_sha: commit.sha,
- });
-
- // GraphQL query to fetch details about the commit, including associated pull requests
- const query = `
- {
- resource(url: "${context.payload.repository.html_url}/commit/${commit.sha}") {
- ... on Commit {
- messageHeadlineHTML
- messageBodyHTML
- associatedPullRequests(first: 10) {
- pageInfo { hasNextPage }
- edges { node { number } }
- }
- }
- }
- }
- `;
-
- response = await github.graphql(query);
-
- // Extracting pull request numbers from the GraphQL response
- for (edge of response.resource.associatedPullRequests.edges) {
- pull_requests.push(edge.node.number);
- }
- }
-
- // Iterating through unique pull request numbers and creating or updating comments for them
- for (id of pull_requests.filter(onlyUnique)) {
- await createCommentForPR(id, currentRelease);
- }
-
+ - name: Comment on released PRs
+ uses: ./.github/actions/release-commenter
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..962c2dc0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@cloudposse/github-workflows",
+ "version": "1.0.0",
+ "description": "Shared GitHub workflows and release automation for Cloud Posse",
+ "private": true,
+ "scripts": {
+ "test": "node --test 'test/**/*.test.js'"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
diff --git a/test/release-author-substitutor.test.js b/test/release-author-substitutor.test.js
new file mode 100644
index 00000000..df832cb5
--- /dev/null
+++ b/test/release-author-substitutor.test.js
@@ -0,0 +1,313 @@
+'use strict';
+
+const { describe, it, before } = require('node:test');
+const assert = require('node:assert/strict');
+const {
+ DEFAULT_LLM_USERS,
+ globToRegex,
+ isLLMUser,
+ extractPRReferences,
+ substituteLLMAuthors,
+} = require('../.github/actions/substitute-llm-authors/release-author-substitutor');
+
+describe('DEFAULT_LLM_USERS', () => {
+ it('is a non-empty array', () => {
+ assert.ok(Array.isArray(DEFAULT_LLM_USERS));
+ assert.ok(DEFAULT_LLM_USERS.length > 0);
+ });
+
+ it('includes github-copilot[bot]', () => {
+ assert.ok(DEFAULT_LLM_USERS.includes('github-copilot[bot]'));
+ });
+
+ it('includes copilot', () => {
+ assert.ok(DEFAULT_LLM_USERS.includes('copilot'));
+ });
+
+ it('includes devin-ai-integration[bot]', () => {
+ assert.ok(DEFAULT_LLM_USERS.includes('devin-ai-integration[bot]'));
+ });
+});
+
+describe('globToRegex', () => {
+ it('matches exact string with no wildcards', () => {
+ const re = globToRegex('copilot');
+ assert.ok(re.test('copilot'));
+ assert.ok(!re.test('xcopilotx'));
+ assert.ok(!re.test('copilot-extra'));
+ });
+
+ it('matches strings with leading wildcard', () => {
+ const re = globToRegex('*bot]');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('[bot]'));
+ assert.ok(!re.test('github-actions'));
+ });
+
+ it('matches strings with trailing wildcard', () => {
+ const re = globToRegex('github-copilot*');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('github-copilot'));
+ assert.ok(!re.test('github-actions[bot]'));
+ });
+
+ it('matches strings with surrounding wildcards', () => {
+ const re = globToRegex('*copilot*');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('copilot'));
+ assert.ok(re.test('my-copilot-assistant'));
+ assert.ok(!re.test('githubbot'));
+ });
+
+ it('escapes special regex characters in the pattern', () => {
+ const re = globToRegex('github-copilot[bot]');
+ assert.ok(re.test('github-copilot[bot]'));
+ // Should NOT match with arbitrary characters in place of [bot]
+ assert.ok(!re.test('github-copilotXbotY'));
+ });
+
+ it('handles multiple wildcards', () => {
+ const re = globToRegex('github*copilot*');
+ assert.ok(re.test('github-copilot[bot]'));
+ assert.ok(re.test('github_copilot'));
+ assert.ok(!re.test('copilot-github'));
+ });
+});
+
+describe('isLLMUser', () => {
+ describe('with default LLM users list', () => {
+ it('returns true for github-copilot[bot]', () => {
+ assert.ok(isLLMUser('github-copilot[bot]'));
+ });
+
+ it('returns true for copilot', () => {
+ assert.ok(isLLMUser('copilot'));
+ });
+
+ it('returns true for devin-ai-integration[bot]', () => {
+ assert.ok(isLLMUser('devin-ai-integration[bot]'));
+ });
+
+ it('returns false for a regular human user', () => {
+ assert.ok(!isLLMUser('octocat'));
+ assert.ok(!isLLMUser('alice'));
+ assert.ok(!isLLMUser('bob'));
+ });
+
+ it('returns false for dependabot (not in default LLM list)', () => {
+ assert.ok(!isLLMUser('dependabot[bot]'));
+ });
+
+ it('is case-insensitive', () => {
+ assert.ok(isLLMUser('GITHUB-COPILOT[BOT]'));
+ assert.ok(isLLMUser('Copilot'));
+ assert.ok(isLLMUser('GitHub-Copilot[Bot]'));
+ });
+ });
+
+ describe('with custom LLM users list', () => {
+ it('matches usernames in the custom list', () => {
+ const customList = ['my-ai-bot', 'custom-llm'];
+ assert.ok(isLLMUser('my-ai-bot', customList));
+ assert.ok(isLLMUser('custom-llm', customList));
+ });
+
+ it('does not match usernames from the default list when custom list is provided', () => {
+ const customList = ['my-ai-bot'];
+ assert.ok(!isLLMUser('github-copilot[bot]', customList));
+ });
+
+ it('supports glob patterns in custom list', () => {
+ const withGlob = ['*copilot*'];
+ assert.ok(isLLMUser('github-copilot[bot]', withGlob));
+ assert.ok(isLLMUser('copilot-assistant', withGlob));
+ assert.ok(isLLMUser('my-copilot', withGlob));
+ assert.ok(!isLLMUser('octocat', withGlob));
+ });
+
+ it('supports prefix glob patterns', () => {
+ const withGlob = ['devin-*'];
+ assert.ok(isLLMUser('devin-ai-integration[bot]', withGlob));
+ assert.ok(!isLLMUser('github-copilot[bot]', withGlob));
+ });
+
+ it('is case-insensitive with glob patterns', () => {
+ const withGlob = ['*COPILOT*'];
+ assert.ok(isLLMUser('github-copilot[bot]', withGlob));
+ });
+ });
+});
+
+describe('extractPRReferences', () => {
+ it('extracts a single PR reference', () => {
+ const body = 'Some feature @github-copilot[bot] (#123)';
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 1);
+ assert.strictEqual(refs[0].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[0].prNumber, 123);
+ assert.strictEqual(refs[0].fullMatch, '@github-copilot[bot] (#123)');
+ });
+
+ it('extracts multiple PR references', () => {
+ const body = [
+ 'Feature A @alice (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ 'Feature C @bob (#102)',
+ ].join('\n');
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 3);
+ assert.strictEqual(refs[0].author, 'alice');
+ assert.strictEqual(refs[0].prNumber, 100);
+ assert.strictEqual(refs[1].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[1].prNumber, 101);
+ assert.strictEqual(refs[2].author, 'bob');
+ assert.strictEqual(refs[2].prNumber, 102);
+ });
+
+ it('returns an empty array when there are no PR references', () => {
+ const body = 'No PR references here';
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 0);
+ });
+
+ it('handles the HTML change-template format from release-drafter', () => {
+ const body = `
+ Add new feature @github-copilot[bot] (#42)
+ Some body text
+ `;
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 1);
+ assert.strictEqual(refs[0].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[0].prNumber, 42);
+ });
+
+ it('handles a full realistic release notes body', () => {
+ const body = `## π Enhancements
+
+
+ Add feature X @github-copilot[bot] (#10)
+ Feature description
+
+
+
+ Refactor Y @alice (#11)
+ Refactor description
+
+
+## π Bug Fixes
+
+
+ Fix bug Z @devin-ai-integration[bot] (#12)
+ Bug fix description
+ `;
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 3);
+ assert.strictEqual(refs[0].author, 'github-copilot[bot]');
+ assert.strictEqual(refs[1].author, 'alice');
+ assert.strictEqual(refs[2].author, 'devin-ai-integration[bot]');
+ });
+
+ it('handles usernames with hyphens and dots', () => {
+ const body = '@some-user.name (#99)';
+ const refs = extractPRReferences(body);
+ assert.strictEqual(refs.length, 1);
+ assert.strictEqual(refs[0].author, 'some-user.name');
+ assert.strictEqual(refs[0].prNumber, 99);
+ });
+});
+
+describe('substituteLLMAuthors', () => {
+ it('replaces a single LLM author with a human assignee', () => {
+ const body = 'Add feature @github-copilot[bot] (#123)';
+ const subs = [{ prNumber: 123, oldAuthor: 'github-copilot[bot]', newAuthor: 'alice' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.strictEqual(result, 'Add feature @alice (#123)');
+ });
+
+ it('replaces multiple LLM authors with different human assignees', () => {
+ const body = [
+ 'Feature A @github-copilot[bot] (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ ].join('\n');
+ const subs = [
+ { prNumber: 100, oldAuthor: 'github-copilot[bot]', newAuthor: 'alice' },
+ { prNumber: 101, oldAuthor: 'github-copilot[bot]', newAuthor: 'bob' },
+ ];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@alice (#100)'));
+ assert.ok(result.includes('@bob (#101)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ });
+
+ it('does not modify entries for non-LLM authors', () => {
+ const body = [
+ 'Feature A @alice (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ ].join('\n');
+ const subs = [{ prNumber: 101, oldAuthor: 'github-copilot[bot]', newAuthor: 'bob' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@alice (#100)'));
+ assert.ok(result.includes('@bob (#101)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ });
+
+ it('returns the original body when no substitutions are provided', () => {
+ const body = 'Feature @alice (#100)';
+ const result = substituteLLMAuthors(body, []);
+ assert.strictEqual(result, body);
+ });
+
+ it('handles the HTML change-template format from release-drafter', () => {
+ const body = `
+ Add feature @github-copilot[bot] (#42)
+ Body text
+ `;
+ const subs = [{ prNumber: 42, oldAuthor: 'github-copilot[bot]', newAuthor: 'reviewer' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@reviewer (#42)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ });
+
+ it('only replaces the matching PR number, not other occurrences', () => {
+ // Same bot authored two PRs; only PR #100 should be substituted
+ const body = [
+ 'Feature A @github-copilot[bot] (#100)',
+ 'Feature B @github-copilot[bot] (#101)',
+ ].join('\n');
+ const subs = [{ prNumber: 100, oldAuthor: 'github-copilot[bot]', newAuthor: 'alice' }];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@alice (#100)'));
+ assert.ok(result.includes('@github-copilot[bot] (#101)'));
+ });
+
+ it('handles a full realistic release notes body', () => {
+ const body = `## π Enhancements
+
+
+ Add feature X @github-copilot[bot] (#10)
+ Feature description
+
+
+
+ Refactor Y @alice (#11)
+ Refactor description
+
+
+## π Bug Fixes
+
+
+ Fix bug Z @devin-ai-integration[bot] (#12)
+ Bug fix description
+ `;
+ const subs = [
+ { prNumber: 10, oldAuthor: 'github-copilot[bot]', newAuthor: 'carol' },
+ { prNumber: 12, oldAuthor: 'devin-ai-integration[bot]', newAuthor: 'dave' },
+ ];
+ const result = substituteLLMAuthors(body, subs);
+ assert.ok(result.includes('@carol (#10)'));
+ assert.ok(result.includes('@alice (#11)'));
+ assert.ok(result.includes('@dave (#12)'));
+ assert.ok(!result.includes('@github-copilot[bot]'));
+ assert.ok(!result.includes('@devin-ai-integration[bot]'));
+ });
+});