diff --git a/.github/scripts/bot-comment-dismiss.js b/.github/scripts/bot-comment-dismiss.js index 7875a74d1..95944edcf 100644 --- a/.github/scripts/bot-comment-dismiss.js +++ b/.github/scripts/bot-comment-dismiss.js @@ -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) { @@ -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) { @@ -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 = {}) { diff --git a/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js b/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js index 7875a74d1..95944edcf 100644 --- a/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js +++ b/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js @@ -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) { @@ -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) { @@ -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 = {}) { diff --git a/tests/test/dismiss/dismiss-bot-comment.test.js b/tests/test/dismiss/dismiss-bot-comment.test.js new file mode 100644 index 000000000..bf57c17fe --- /dev/null +++ b/tests/test/dismiss/dismiss-bot-comment.test.js @@ -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]', + }), + ]); + }); +});