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
107 changes: 107 additions & 0 deletions .github/actions/release-commenter/action.yml
Original file line number Diff line number Diff line change
@@ -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 = `<!-- release-pr-comment:${release.id} -->`;
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);
}
142 changes: 142 additions & 0 deletions .github/actions/substitute-llm-authors/action.yml
Original file line number Diff line number Diff line change
@@ -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)`);
Original file line number Diff line number Diff line change
@@ -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,
};
20 changes: 20 additions & 0 deletions .github/workflows/shared-auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading