Skip to content
Merged
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
86 changes: 73 additions & 13 deletions .github/scripts/bot-comment-dismiss.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* await autoDismissReviewComments({ github, withRetry, ... });
*/

const { buildIgnoredPathMatchers, shouldIgnorePath } = require('./pr-context-graphql');
const DEFAULT_IGNORED_PATTERNS = ['.agents/**'];

function parseCsv(value) {
if (!value) {
Expand All @@ -31,12 +31,78 @@ function normalizeAuthors(authors) {
return new Set((authors || []).map((author) => String(author || '').toLowerCase()).filter(Boolean));
}

function normalizeSlashes(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}

function hasGlobPattern(value) {
return /[*?]/.test(value);
}

function normalizePattern(value) {
const normalized = normalizeSlashes(value);
if (!normalized) {
return null;
}
if (normalized.endsWith('/')) {
return `${normalized}**`;
}
if (!hasGlobPattern(normalized)) {
return normalized;
}
return normalized;
}

function patternToRegex(pattern) {
const normalized = normalizeSlashes(pattern);
let regex = '';
for (let i = 0; i < normalized.length; i += 1) {
const char = normalized[i];
if (char === '*') {
const next = normalized[i + 1];
if (next === '*') {
regex += '.*';
i += 1;
} else {
regex += '[^/]*';
}
continue;
}
if (char === '?') {
regex += '[^/]';
continue;
}
regex += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${regex}$`);
}

function buildMatchers({ ignoredPaths, ignoredPatterns } = {}) {
const env = {
PR_CONTEXT_IGNORED_PATHS: ignoredPaths && ignoredPaths.length ? ignoredPaths.join(',') : undefined,
PR_CONTEXT_IGNORED_PATTERNS: ignoredPatterns && ignoredPatterns.length ? ignoredPatterns.join(',') : undefined,
};
return buildIgnoredPathMatchers(env);
const patterns = [];
for (const entry of ignoredPaths || []) {
const normalized = normalizePattern(entry);
if (normalized) {
patterns.push(normalized);
}
}
for (const entry of ignoredPatterns || []) {
const normalized = normalizePattern(entry);
if (normalized) {
patterns.push(normalized);
}
}
if (!patterns.length) {
patterns.push(...DEFAULT_IGNORED_PATTERNS);
}
return patterns.map(patternToRegex);
}

function shouldIgnorePath(filename, matchers) {
const normalized = normalizeSlashes(filename);
if (!normalized) {
return false;
}
return matchers.some((pattern) => pattern.test(normalized));
}

function isBotAuthor(comment, botAuthors) {
Expand All @@ -50,13 +116,7 @@ function resolveCommentTimestamp(comment) {
if (!comment) {
return null;
}
return (
comment.created_at ||
comment.createdAt ||
comment.updated_at ||
comment.updatedAt ||
null
);
return comment.created_at || comment.createdAt || null;
}

function collectDismissable(comments, options = {}) {
Expand Down
86 changes: 73 additions & 13 deletions templates/consumer-repo/.github/scripts/bot-comment-dismiss.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* await autoDismissReviewComments({ github, withRetry, ... });
*/

const { buildIgnoredPathMatchers, shouldIgnorePath } = require('./pr-context-graphql');
const DEFAULT_IGNORED_PATTERNS = ['.agents/**'];

function parseCsv(value) {
if (!value) {
Expand All @@ -31,12 +31,78 @@ function normalizeAuthors(authors) {
return new Set((authors || []).map((author) => String(author || '').toLowerCase()).filter(Boolean));
}

function normalizeSlashes(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}

function hasGlobPattern(value) {
return /[*?]/.test(value);
}

function normalizePattern(value) {
const normalized = normalizeSlashes(value);
if (!normalized) {
return null;
}
if (normalized.endsWith('/')) {
return `${normalized}**`;
}
if (!hasGlobPattern(normalized)) {
return normalized;
}
return normalized;
}

function patternToRegex(pattern) {
const normalized = normalizeSlashes(pattern);
let regex = '';
for (let i = 0; i < normalized.length; i += 1) {
const char = normalized[i];
if (char === '*') {
const next = normalized[i + 1];
if (next === '*') {
regex += '.*';
i += 1;
} else {
regex += '[^/]*';
}
continue;
}
if (char === '?') {
regex += '[^/]';
continue;
}
regex += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${regex}$`);
}

function buildMatchers({ ignoredPaths, ignoredPatterns } = {}) {
const env = {
PR_CONTEXT_IGNORED_PATHS: ignoredPaths && ignoredPaths.length ? ignoredPaths.join(',') : undefined,
PR_CONTEXT_IGNORED_PATTERNS: ignoredPatterns && ignoredPatterns.length ? ignoredPatterns.join(',') : undefined,
};
return buildIgnoredPathMatchers(env);
const patterns = [];
for (const entry of ignoredPaths || []) {
const normalized = normalizePattern(entry);
if (normalized) {
patterns.push(normalized);
}
}
for (const entry of ignoredPatterns || []) {
const normalized = normalizePattern(entry);
if (normalized) {
patterns.push(normalized);
}
}
if (!patterns.length) {
patterns.push(...DEFAULT_IGNORED_PATTERNS);
}
return patterns.map(patternToRegex);
}

function shouldIgnorePath(filename, matchers) {
const normalized = normalizeSlashes(filename);
if (!normalized) {
return false;
}
return matchers.some((pattern) => pattern.test(normalized));
}

function isBotAuthor(comment, botAuthors) {
Expand All @@ -50,13 +116,7 @@ function resolveCommentTimestamp(comment) {
if (!comment) {
return null;
}
return (
comment.created_at ||
comment.createdAt ||
comment.updated_at ||
comment.updatedAt ||
null
);
return comment.created_at || comment.createdAt || null;
}

function collectDismissable(comments, options = {}) {
Expand Down
182 changes: 182 additions & 0 deletions tests/test/dismiss/dismiss-bot-comment.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
'use strict';

const { describe, it } = require('node:test');
const assert = require('node:assert');

const {
autoDismissReviewComments,
collectDismissable,
formatDismissLog,
} = require('../../../.github/scripts/bot-comment-dismiss');

describe('bot-comment-dismiss glob matching', () => {
it('matches nested paths under .agents/**', () => {
const dismissable = collectDismissable(
[
{ id: 1, path: '.agents/a/b/c.yml', user: { login: 'copilot[bot]' } },
{ id: 2, path: 'src/agents/file.ts', user: { login: 'copilot[bot]' } },
],
{
ignoredPaths: ['.agents/**'],
botAuthors: ['copilot[bot]'],
}
);

assert.deepStrictEqual(dismissable, [
{ id: 1, path: '.agents/a/b/c.yml', author: 'copilot[bot]' },
]);
});

it('does not dismiss non-matching paths', () => {
const dismissable = collectDismissable(
[
{ id: 3, path: 'src/agents/file.ts', user: { login: 'copilot[bot]' } },
],
{
ignoredPaths: ['.agents/**'],
botAuthors: ['copilot[bot]'],
}
);

assert.deepStrictEqual(dismissable, []);
});

it('dismisses only ignored-path comments in mixed reviews', async () => {
const deleted = [];
const github = {
rest: {
pulls: {
listReviewComments: async () => ({
data: [
{
id: 11,
path: '.agents/issue-test-ledger.yml',
user: { login: 'copilot[bot]' },
created_at: '2026-02-08T12:00:10.000Z',
},
{
id: 12,
path: 'src/app.ts',
user: { login: 'copilot[bot]' },
created_at: '2026-02-08T12:00:10.000Z',
},
],
}),
deleteReviewComment: async ({ comment_id }) => {
deleted.push(comment_id);
},
},
},
};

const result = await autoDismissReviewComments({
github,
owner: 'octo',
repo: 'repo',
pullNumber: 123,
ignoredPaths: ['.agents/**'],
botAuthors: ['copilot[bot]'],
maxAgeSeconds: 60,
now: Date.parse('2026-02-08T12:00:30.000Z'),
});

assert.deepStrictEqual(result.dismissable, [
{ id: 11, path: '.agents/issue-test-ledger.yml', author: 'copilot[bot]' },
]);
assert.deepStrictEqual(deleted, [11]);
});

it('skips old ignored-path comments when maxAgeSeconds is set', async () => {
const deleted = [];
const github = {
rest: {
pulls: {
listReviewComments: async () => ({
data: [
{
id: 21,
path: '.agents/old-ledger.yml',
user: { login: 'copilot[bot]' },
created_at: '2026-02-08T12:00:00.000Z',
},
{
id: 22,
path: '.agents/new-ledger.yml',
user: { login: 'copilot[bot]' },
created_at: '2026-02-08T12:00:50.000Z',
},
],
}),
deleteReviewComment: async ({ comment_id }) => {
deleted.push(comment_id);
},
},
},
};

const result = await autoDismissReviewComments({
github,
owner: 'octo',
repo: 'repo',
pullNumber: 123,
ignoredPaths: ['.agents/**'],
botAuthors: ['copilot[bot]'],
maxAgeSeconds: 30,
now: Date.parse('2026-02-08T12:01:00.000Z'),
});

assert.deepStrictEqual(result.dismissable, [
{ id: 22, path: '.agents/new-ledger.yml', author: 'copilot[bot]' },
]);
assert.deepStrictEqual(deleted, [22]);
});

it('returns a structured log entry for each dismissal', async () => {
const deleted = [];
const github = {
rest: {
pulls: {
listReviewComments: async () => ({
data: [
{
id: 31,
path: '.agents/issue-test-ledger.yml',
user: { login: 'copilot[bot]' },
created_at: '2026-02-08T12:00:10.000Z',
},
{
id: 32,
path: 'src/app.ts',
user: { login: 'copilot[bot]' },
created_at: '2026-02-08T12:00:10.000Z',
},
],
}),
deleteReviewComment: async ({ comment_id }) => {
deleted.push(comment_id);
},
},
},
};

const result = await autoDismissReviewComments({
github,
owner: 'octo',
repo: 'repo',
pullNumber: 123,
ignoredPaths: ['.agents/**'],
botAuthors: ['copilot[bot]'],
maxAgeSeconds: 60,
now: Date.parse('2026-02-08T12:00:30.000Z'),
});

assert.deepStrictEqual(deleted, [31]);
assert.deepStrictEqual(result.logs, [
formatDismissLog({
id: 31,
path: '.agents/issue-test-ledger.yml',
author: 'copilot[bot]',
}),
]);
});
});
Loading