From 2e3c2f5cc34bba4132e548516e737d79761e0c0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:32:07 +0000 Subject: [PATCH 1/3] Initial plan From ea7547f67715e2ea3c623221a7d37df64ad81d05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:45:56 +0000 Subject: [PATCH 2/3] feat: substitute LLM bot authors with human assignees in release notes - Add src/release-author-substitutor.js with utility functions for detecting LLM/AI bot users and replacing their author mentions with the PR's human assignee in release note bodies - Add test/release-author-substitutor.test.js with 34 unit tests (Node.js built-in test runner, all passing) - Add package.json with npm test script - Update shared-auto-release.yml with new llm-users input and a post-processing step that patches the release body when LLM-authored PRs are found Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- .github/workflows/shared-auto-release.yml | 129 +++++++++ package.json | 12 + src/release-author-substitutor.js | 97 +++++++ test/release-author-substitutor.test.js | 313 ++++++++++++++++++++++ 4 files changed, 551 insertions(+) create mode 100644 package.json create mode 100644 src/release-author-substitutor.js create mode 100644 test/release-author-substitutor.test.js diff --git a/.github/workflows/shared-auto-release.yml b/.github/workflows/shared-auto-release.yml index 9ec2a288..ee214598 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,125 @@ 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. + # The utility functions below mirror src/release-author-substitutor.js (unit-tested). + - name: Substitute LLM authors in release notes + if: ${{ steps.drafter.outputs.id != '' }} + uses: actions/github-script@v7 + env: + LLM_USERS: ${{ inputs.llm-users }} + with: + github-token: ${{ steps.github-app.outputs.token }} + retries: 3 + script: | + // Default list of LLM/AI assistant usernames. + // Mirrors src/release-author-substitutor.js DEFAULT_LLM_USERS. + 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; + while ((match = prPattern.exec(body)) !== null) { + // fullMatch is omitted here (unused); see src/release-author-substitutor.js for full shape + 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('${{ steps.drafter.outputs.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)`); + # - uses: actions/github-script@v7 # if: ${{ steps.context.outputs.config == 'auto-feature-release.yml' && steps.drafter.outputs.id != '' }} # with: 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/src/release-author-substitutor.js b/src/release-author-substitutor.js new file mode 100644 index 00000000..f1bb3aef --- /dev/null +++ b/src/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/test/release-author-substitutor.test.js b/test/release-author-substitutor.test.js new file mode 100644 index 00000000..eb4353db --- /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('../src/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]')); + }); +}); From 5472c46ce81e75b70c8d6d8b9c29175cfa97a9bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:03:20 +0000 Subject: [PATCH 3/3] refactor: move inline github-scripts to co-located composite actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the two large inline github-script blocks from reusable workflows into composite actions co-located in .github/actions/, following the Atmos repository pattern. - .github/actions/substitute-llm-authors/action.yml: composite action extracted from shared-auto-release.yml; uses process.env instead of template interpolation for the release-id (security improvement) - .github/actions/substitute-llm-authors/release-author-substitutor.js: moved from src/ to co-locate with the composite action - .github/actions/release-commenter/action.yml: composite action extracted from shared-release-branches.yml; cleaned up var/for-in patterns - shared-auto-release.yml: 116-line inline script β†’ 4-line composite action call - shared-release-branches.yml: 131-line inline script β†’ 2-line composite action call - test/release-author-substitutor.test.js: updated import path Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- .github/actions/release-commenter/action.yml | 107 +++++++++++++ .../actions/substitute-llm-authors/action.yml | 142 ++++++++++++++++++ .../release-author-substitutor.js | 0 .github/workflows/shared-auto-release.yml | 117 +-------------- .github/workflows/shared-release-branches.yml | 130 +--------------- test/release-author-substitutor.test.js | 2 +- 6 files changed, 256 insertions(+), 242 deletions(-) create mode 100644 .github/actions/release-commenter/action.yml create mode 100644 .github/actions/substitute-llm-authors/action.yml rename {src => .github/actions/substitute-llm-authors}/release-author-substitutor.js (100%) 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/src/release-author-substitutor.js b/.github/actions/substitute-llm-authors/release-author-substitutor.js similarity index 100% rename from src/release-author-substitutor.js rename to .github/actions/substitute-llm-authors/release-author-substitutor.js diff --git a/.github/workflows/shared-auto-release.yml b/.github/workflows/shared-auto-release.yml index ee214598..38835a46 100644 --- a/.github/workflows/shared-auto-release.yml +++ b/.github/workflows/shared-auto-release.yml @@ -140,122 +140,13 @@ jobs: # 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. - # The utility functions below mirror src/release-author-substitutor.js (unit-tested). - name: Substitute LLM authors in release notes if: ${{ steps.drafter.outputs.id != '' }} - uses: actions/github-script@v7 - env: - LLM_USERS: ${{ inputs.llm-users }} + uses: ./.github/actions/substitute-llm-authors with: - github-token: ${{ steps.github-app.outputs.token }} - retries: 3 - script: | - // Default list of LLM/AI assistant usernames. - // Mirrors src/release-author-substitutor.js DEFAULT_LLM_USERS. - 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; - while ((match = prPattern.exec(body)) !== null) { - // fullMatch is omitted here (unused); see src/release-author-substitutor.js for full shape - 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('${{ steps.drafter.outputs.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)`); + 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 != '' }} 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/test/release-author-substitutor.test.js b/test/release-author-substitutor.test.js index eb4353db..df832cb5 100644 --- a/test/release-author-substitutor.test.js +++ b/test/release-author-substitutor.test.js @@ -8,7 +8,7 @@ const { isLLMUser, extractPRReferences, substituteLLMAuthors, -} = require('../src/release-author-substitutor'); +} = require('../.github/actions/substitute-llm-authors/release-author-substitutor'); describe('DEFAULT_LLM_USERS', () => { it('is a non-empty array', () => {