diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 836bbd069..d6f5caec0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -31,6 +31,14 @@ After reading CLAUDE.md, these are the key architectural points: If a user says "read X" or "check Y", do it **immediately** as your next action. Not later. Not after you've started something else. Immediately. +## Ignore Ledger Tracking Files + +Do not review or comment on ledger tracking files used for agent automation: +- `.agents/issue-*-ledger.yml` +- Any file under `.agents/` that is not application code + +These files are internal task trackers and should never receive Copilot review comments or suggestions. + ## Template Changes Any change to workflows that consumers use must be reflected in BOTH: diff --git a/.github/scripts/__tests__/bot-comment-dismiss.test.js b/.github/scripts/__tests__/bot-comment-dismiss.test.js new file mode 100644 index 000000000..d135e754a --- /dev/null +++ b/.github/scripts/__tests__/bot-comment-dismiss.test.js @@ -0,0 +1,260 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { + autoDismissReviewComments, + collectDismissable, + dismissReviewComments, + formatDismissLog, + runCli, +} = require('../bot-comment-dismiss'); + +describe('bot-comment-dismiss', () => { + it('collects bot comments in ignored paths', () => { + const comments = [ + { id: 1, path: '.agents/issue-1-ledger.yml', user: { login: 'copilot[bot]' } }, + { id: 2, path: 'src/app.js', user: { login: 'copilot[bot]' } }, + { id: 3, path: '.agents/notes.txt', user: { login: 'coderabbitai[bot]' } }, + { id: 4, path: '.agents/issue-2-ledger.yml', user: { login: 'octocat' } }, + ]; + + const dismissable = collectDismissable(comments, { + ignoredPaths: ['.agents/'], + botAuthors: ['copilot[bot]', 'coderabbitai[bot]'], + }); + + assert.deepStrictEqual(dismissable, [ + { id: 1, path: '.agents/issue-1-ledger.yml', author: 'copilot[bot]' }, + { id: 3, path: '.agents/notes.txt', author: 'coderabbitai[bot]' }, + ]); + }); + + it('formats log lines with author and path', () => { + const log = formatDismissLog({ id: 42, path: '.agents/issue-9-ledger.yml', author: 'copilot[bot]' }); + assert.equal(log, 'Auto-dismissed review comment 42 by copilot[bot] in .agents/issue-9-ledger.yml'); + }); + + it('runs cli with env overrides', () => { + const env = { + COMMENTS_JSON: JSON.stringify([ + { + id: 9, + path: '.agents/issue-9-ledger.yml', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:00.000Z', + }, + { + id: 10, + path: 'src/app.js', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:00.000Z', + }, + ]), + IGNORED_PATHS: '.agents/', + BOT_AUTHORS: 'copilot[bot]', + MAX_AGE_SECONDS: '30', + NOW_EPOCH_MS: String(Date.parse('2026-02-08T12:00:20.000Z')), + }; + + const result = runCli(env); + + assert.deepStrictEqual(result.dismissable, [ + { id: 9, path: '.agents/issue-9-ledger.yml', author: 'copilot[bot]' }, + ]); + assert.deepStrictEqual(result.logs, [ + 'Auto-dismissed review comment 9 by copilot[bot] in .agents/issue-9-ledger.yml', + ]); + }); + + it('uses default max age of 30 seconds when not provided', () => { + const env = { + COMMENTS_JSON: JSON.stringify([ + { + id: 21, + path: '.agents/issue-21-ledger.yml', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:00.000Z', + }, + { + id: 22, + path: '.agents/issue-22-ledger.yml', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:40.000Z', + }, + ]), + IGNORED_PATHS: '.agents/', + BOT_AUTHORS: 'copilot[bot]', + NOW_EPOCH_MS: String(Date.parse('2026-02-08T12:01:00.000Z')), + }; + + const result = runCli(env); + + assert.deepStrictEqual(result.dismissable, [ + { id: 22, path: '.agents/issue-22-ledger.yml', author: 'copilot[bot]' }, + ]); + }); + + it('filters out ignored-path comments older than max age', () => { + const dismissable = collectDismissable( + [ + { + id: 11, + path: '.agents/issue-11-ledger.yml', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:00.000Z', + }, + { + id: 12, + path: '.agents/issue-12-ledger.yml', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:40.000Z', + }, + ], + { + ignoredPaths: ['.agents/'], + botAuthors: ['copilot[bot]'], + maxAgeSeconds: 30, + now: Date.parse('2026-02-08T12:01:00.000Z'), + } + ); + + assert.deepStrictEqual(dismissable, [ + { id: 12, path: '.agents/issue-12-ledger.yml', author: 'copilot[bot]' }, + ]); + }); + + it('accepts GraphQL-style createdAt timestamps', () => { + const dismissable = collectDismissable( + [ + { + id: 31, + path: '.agents/issue-31-ledger.yml', + user: { login: 'copilot[bot]' }, + createdAt: '2026-02-08T12:00:50.000Z', + }, + ], + { + ignoredPaths: ['.agents/'], + botAuthors: ['copilot[bot]'], + maxAgeSeconds: 30, + now: Date.parse('2026-02-08T12:01:10.000Z'), + } + ); + + assert.deepStrictEqual(dismissable, [ + { id: 31, path: '.agents/issue-31-ledger.yml', author: 'copilot[bot]' }, + ]); + }); + + it('dismisses comments and logs each dismissal', async () => { + const deleted = []; + const github = { + rest: { + pulls: { + deleteReviewComment: async ({ comment_id }) => { + deleted.push(comment_id); + }, + }, + }, + }; + const logs = []; + const logger = { + info: (line) => logs.push(line), + }; + + const result = await dismissReviewComments({ + github, + owner: 'octo', + repo: 'repo', + dismissable: [ + { id: 1, path: '.agents/issue-1-ledger.yml', author: 'copilot[bot]' }, + { id: 2, path: '.agents/issue-2-ledger.yml', author: 'coderabbitai[bot]' }, + ], + logger, + }); + + assert.deepStrictEqual(deleted, [1, 2]); + assert.deepStrictEqual(result.dismissed, [ + { id: 1, path: '.agents/issue-1-ledger.yml', author: 'copilot[bot]' }, + { id: 2, path: '.agents/issue-2-ledger.yml', author: 'coderabbitai[bot]' }, + ]); + assert.deepStrictEqual(result.logs, [ + 'Auto-dismissed review comment 1 by copilot[bot] in .agents/issue-1-ledger.yml', + 'Auto-dismissed review comment 2 by coderabbitai[bot] in .agents/issue-2-ledger.yml', + ]); + assert.deepStrictEqual(logs, result.logs); + }); + + it('lists review comments and auto-dismisses ignored-path bot comments', async () => { + const deleted = []; + const github = { + rest: { + pulls: { + listReviewComments: async () => ({ + data: [ + { + id: 7, + path: '.agents/issue-7-ledger.yml', + user: { login: 'copilot[bot]' }, + created_at: '2026-02-08T12:00:10.000Z', + }, + { + id: 8, + path: 'src/app.js', + 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: 30, + now: Date.parse('2026-02-08T12:00:20.000Z'), + }); + + assert.deepStrictEqual(result.dismissable, [ + { id: 7, path: '.agents/issue-7-ledger.yml', author: 'copilot[bot]' }, + ]); + assert.deepStrictEqual(deleted, [7]); + }); + + it('tracks failures when dismissal fails', async () => { + const github = { + rest: { + pulls: { + deleteReviewComment: async () => { + throw new Error('nope'); + }, + }, + }, + }; + + const result = await dismissReviewComments({ + github, + owner: 'octo', + repo: 'repo', + dismissable: [ + { id: 99, path: '.agents/issue-99-ledger.yml', author: 'copilot[bot]' }, + ], + logger: { warn: () => {} }, + }); + + assert.deepStrictEqual(result.dismissed, []); + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].id, 99); + }); +}); diff --git a/.github/scripts/__tests__/connector-exclusion-smoke.test.js b/.github/scripts/__tests__/connector-exclusion-smoke.test.js new file mode 100644 index 000000000..5ceb6ce61 --- /dev/null +++ b/.github/scripts/__tests__/connector-exclusion-smoke.test.js @@ -0,0 +1,24 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { filterPaths } = require('../connector-exclusion-smoke'); + +describe('connector-exclusion-smoke', () => { + it('filters .agents ledger files using default ignore rules', () => { + const input = [ + '.agents/issue-1234-ledger.yml', + '.agents/notes.txt', + 'src/index.js' + ]; + + const result = filterPaths(input); + + assert.deepStrictEqual(result.ignored, [ + '.agents/issue-1234-ledger.yml', + '.agents/notes.txt' + ]); + assert.deepStrictEqual(result.kept, ['src/index.js']); + }); +}); diff --git a/.github/scripts/__tests__/pr-context-graphql.test.js b/.github/scripts/__tests__/pr-context-graphql.test.js index a920ec2fa..36d473e2c 100644 --- a/.github/scripts/__tests__/pr-context-graphql.test.js +++ b/.github/scripts/__tests__/pr-context-graphql.test.js @@ -98,6 +98,40 @@ const mockPRBasicResponse = { } }; +const mockPRContextResponseWithAgents = { + repository: { + pullRequest: { + ...mockPRContextResponse.repository.pullRequest, + files: { + totalCount: 4, + nodes: [ + { path: 'src/index.js', additions: 10, deletions: 5, changeType: 'MODIFIED' }, + { path: 'tests/test.js', additions: 20, deletions: 0, changeType: 'ADDED' }, + { path: 'README.md', additions: 2, deletions: 1, changeType: 'MODIFIED' }, + { path: '.agents/issue-1234-ledger.yml', additions: 3, deletions: 1, changeType: 'MODIFIED' } + ] + } + } + } +}; + +const mockPRContextResponseWithDocs = { + repository: { + pullRequest: { + ...mockPRContextResponse.repository.pullRequest, + files: { + totalCount: 4, + nodes: [ + { path: 'src/index.js', additions: 10, deletions: 5, changeType: 'MODIFIED' }, + { path: 'docs/guide/intro.md', additions: 4, deletions: 0, changeType: 'ADDED' }, + { path: 'README.md', additions: 2, deletions: 1, changeType: 'MODIFIED' }, + { path: '.agents/issue-1234-ledger.yml', additions: 3, deletions: 1, changeType: 'MODIFIED' } + ] + } + } + } +}; + describe('fetchPRContext', () => { it('fetches and transforms PR context correctly', async () => { const mockGithub = { @@ -142,6 +176,55 @@ describe('fetchPRContext', () => { assert.deepStrictEqual(context.files.paths, ['src/index.js', 'tests/test.js', 'README.md']); assert.strictEqual(context.files.detailed.length, 3); }); + + it('filters ignored .agents ledger files by default', async () => { + const mockGithub = { + graphql: mock.fn(async () => mockPRContextResponseWithAgents) + }; + + const context = await fetchPRContext(mockGithub, 'owner', 'repo', 42); + + assert.strictEqual(context.files.total, 3); + assert.strictEqual(context.files.ignored, 1); + assert.strictEqual(context.files.unfilteredTotal, 4); + assert.deepStrictEqual(context.files.ignoredPaths, ['.agents/issue-1234-ledger.yml']); + assert.deepStrictEqual(context.files.paths, ['src/index.js', 'tests/test.js', 'README.md']); + }); + + it('respects custom ignored path patterns from env', async () => { + const mockGithub = { + graphql: mock.fn(async () => mockPRContextResponseWithDocs) + }; + const originalPaths = process.env.PR_CONTEXT_IGNORED_PATHS; + const originalPatterns = process.env.PR_CONTEXT_IGNORED_PATTERNS; + + process.env.PR_CONTEXT_IGNORED_PATHS = 'docs/'; + process.env.PR_CONTEXT_IGNORED_PATTERNS = '.agents/issue-*-ledger.yml,docs/**/*.md'; + + try { + const context = await fetchPRContext(mockGithub, 'owner', 'repo', 42); + + assert.strictEqual(context.files.total, 2); + assert.strictEqual(context.files.ignored, 2); + assert.strictEqual(context.files.unfilteredTotal, 4); + assert.deepStrictEqual(context.files.ignoredPaths, [ + 'docs/guide/intro.md', + '.agents/issue-1234-ledger.yml' + ]); + assert.deepStrictEqual(context.files.paths, ['src/index.js', 'README.md']); + } finally { + if (originalPaths === undefined) { + delete process.env.PR_CONTEXT_IGNORED_PATHS; + } else { + process.env.PR_CONTEXT_IGNORED_PATHS = originalPaths; + } + if (originalPatterns === undefined) { + delete process.env.PR_CONTEXT_IGNORED_PATTERNS; + } else { + process.env.PR_CONTEXT_IGNORED_PATTERNS = originalPatterns; + } + } + }); it('extracts reviews correctly', async () => { const mockGithub = { @@ -226,6 +309,7 @@ describe('fetchPRContext', () => { assert.strictEqual(context.author, 'unknown'); assert.deepStrictEqual(context.labels, []); assert.strictEqual(context.files.total, 0); + assert.deepStrictEqual(context.files.ignoredPaths, []); assert.strictEqual(context.lastCommit, null); }); }); diff --git a/.github/scripts/bot-comment-dismiss.js b/.github/scripts/bot-comment-dismiss.js new file mode 100644 index 000000000..ea3f46d55 --- /dev/null +++ b/.github/scripts/bot-comment-dismiss.js @@ -0,0 +1,245 @@ +'use strict'; + +/** + * Bot Comment Dismiss - Auto-dismiss ignored bot review comments + * + * Dismisses review comments from bots (Copilot, CodeRabbit, etc.) on files + * that should be ignored (e.g., .agents/ ledgers). + * + * API Wrapper: Use createTokenAwareRetry from github-api-with-retry.js + * to create the withRetry function passed to these functions. + * + * Example: + * const { createTokenAwareRetry } = require('./github-api-with-retry.js'); + * const { withRetry } = await createTokenAwareRetry({ github, core }); + * await autoDismissReviewComments({ github, withRetry, ... }); + */ + +const { buildIgnoredPathMatchers, shouldIgnorePath } = require('./pr-context-graphql'); + +function parseCsv(value) { + if (!value) { + return []; + } + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeAuthors(authors) { + return new Set((authors || []).map((author) => String(author || '').toLowerCase()).filter(Boolean)); +} + +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); +} + +function isBotAuthor(comment, botAuthors) { + if (!comment || !comment.user || !comment.user.login) { + return false; + } + return botAuthors.has(String(comment.user.login).toLowerCase()); +} + +function resolveCommentTimestamp(comment) { + if (!comment) { + return null; + } + return ( + comment.created_at || + comment.createdAt || + comment.updated_at || + comment.updatedAt || + null + ); +} + +function collectDismissable(comments, options = {}) { + const botAuthors = normalizeAuthors(options.botAuthors); + const matchers = buildMatchers({ + ignoredPaths: options.ignoredPaths, + ignoredPatterns: options.ignoredPatterns, + }); + const maxAgeSeconds = + typeof options.maxAgeSeconds === 'number' && Number.isFinite(options.maxAgeSeconds) + ? options.maxAgeSeconds + : null; + const now = typeof options.now === 'number' && Number.isFinite(options.now) ? options.now : Date.now(); + + const dismissable = []; + + for (const comment of comments || []) { + if (!isBotAuthor(comment, botAuthors)) { + continue; + } + const commentPath = comment.path || ''; + if (!shouldIgnorePath(commentPath, matchers)) { + continue; + } + if (maxAgeSeconds !== null) { + const timestamp = resolveCommentTimestamp(comment); + const createdAt = timestamp ? Date.parse(timestamp) : NaN; + if (!Number.isFinite(createdAt)) { + continue; + } + const ageSeconds = (now - createdAt) / 1000; + if (ageSeconds > maxAgeSeconds) { + continue; + } + } + dismissable.push({ + id: comment.id, + path: comment.path, + author: comment.user.login, + }); + } + + return dismissable; +} + +function formatDismissLog(entry) { + const path = entry.path || 'unknown-path'; + const author = entry.author || 'unknown-author'; + return `Auto-dismissed review comment ${entry.id} by ${author} in ${path}`; +} + +async function dismissReviewComments(options = {}) { + const github = options.github; + const dismissable = options.dismissable || []; + const owner = options.owner; + const repo = options.repo; + const withRetry = options.withRetry || ((fn) => fn()); + const logger = options.logger || console; + + if (!github || !github.rest || !github.rest.pulls) { + throw new Error('github client missing rest.pulls'); + } + if (!owner || !repo) { + throw new Error('owner and repo are required'); + } + + const dismissed = []; + const failed = []; + const logs = []; + + for (const entry of dismissable) { + try { + await withRetry(() => + github.rest.pulls.deleteReviewComment({ + owner, + repo, + comment_id: entry.id, + }) + ); + dismissed.push(entry); + const logLine = formatDismissLog(entry); + logs.push(logLine); + if (logger && typeof logger.info === 'function') { + logger.info(logLine); + } else if (logger && typeof logger.log === 'function') { + logger.log(logLine); + } + } catch (error) { + failed.push({ + ...entry, + error: error ? String(error.message || error) : 'unknown error', + }); + if (logger && typeof logger.warning === 'function') { + logger.warning(`Failed to dismiss review comment ${entry.id}: ${error?.message || error}`); + } else if (logger && typeof logger.warn === 'function') { + logger.warn(`Failed to dismiss review comment ${entry.id}: ${error?.message || error}`); + } + } + } + + return { dismissed, failed, logs }; +} + +async function autoDismissReviewComments(options = {}) { + const github = options.github; + const owner = options.owner; + const repo = options.repo; + const pullNumber = options.pullNumber; + const withRetry = options.withRetry || ((fn) => fn()); + const logger = options.logger || console; + + if (!github || !github.rest || !github.rest.pulls) { + throw new Error('github client missing rest.pulls'); + } + if (!owner || !repo) { + throw new Error('owner and repo are required'); + } + if (!pullNumber) { + throw new Error('pullNumber is required'); + } + + const response = await withRetry(() => + github.rest.pulls.listReviewComments({ + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }) + ); + const comments = Array.isArray(response?.data) ? response.data : response || []; + + const dismissable = collectDismissable(comments, { + ignoredPaths: options.ignoredPaths, + ignoredPatterns: options.ignoredPatterns, + botAuthors: options.botAuthors, + maxAgeSeconds: + typeof options.maxAgeSeconds === 'number' && Number.isFinite(options.maxAgeSeconds) + ? options.maxAgeSeconds + : null, + now: options.now, + }); + + const result = await dismissReviewComments({ + github, + owner, + repo, + dismissable, + withRetry, + logger, + }); + + return { dismissable, ...result }; +} + +function runCli(env = process.env) { + const comments = env.COMMENTS_JSON ? JSON.parse(env.COMMENTS_JSON) : []; + const ignoredPaths = parseCsv(env.IGNORED_PATHS); + const ignoredPatterns = parseCsv(env.IGNORED_PATTERNS); + const botAuthors = parseCsv(env.BOT_AUTHORS); + const maxAgeSeconds = env.MAX_AGE_SECONDS ? Number(env.MAX_AGE_SECONDS) : 30; + const now = env.NOW_EPOCH_MS ? Number(env.NOW_EPOCH_MS) : undefined; + + const dismissable = collectDismissable(comments, { + ignoredPaths, + ignoredPatterns, + botAuthors, + maxAgeSeconds: Number.isFinite(maxAgeSeconds) ? maxAgeSeconds : null, + now: Number.isFinite(now) ? now : undefined, + }); + const logs = dismissable.map((entry) => formatDismissLog(entry)); + + return { dismissable, logs }; +} + +if (require.main === module) { + const result = runCli(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +module.exports = { + collectDismissable, + autoDismissReviewComments, + dismissReviewComments, + formatDismissLog, + runCli, +}; diff --git a/.github/scripts/connector-exclusion-smoke.js b/.github/scripts/connector-exclusion-smoke.js new file mode 100644 index 000000000..f21f4badf --- /dev/null +++ b/.github/scripts/connector-exclusion-smoke.js @@ -0,0 +1,51 @@ +'use strict'; + +const { buildIgnoredPathMatchers, shouldIgnorePath } = require('./pr-context-graphql'); + +function filterPaths(paths, env = process.env) { + const matchers = buildIgnoredPathMatchers(env); + const ignored = []; + const kept = []; + + for (const path of paths || []) { + if (shouldIgnorePath(path, matchers)) { + ignored.push(path); + } else { + kept.push(path); + } + } + + return { ignored, kept }; +} + +function readPathsFromEnv() { + if (process.env.PATHS_JSON) { + const parsed = JSON.parse(process.env.PATHS_JSON); + if (Array.isArray(parsed)) { + return parsed; + } + } + if (process.env.PATHS_CSV) { + return process.env.PATHS_CSV.split(',').map((entry) => entry.trim()).filter(Boolean); + } + return null; +} + +if (require.main === module) { + const cliPaths = process.argv.slice(2); + const envPaths = readPathsFromEnv(); + const paths = cliPaths.length ? cliPaths : envPaths; + + if (!paths || !paths.length) { + console.error('Usage: node connector-exclusion-smoke.js '); + console.error('Or set PATHS_JSON (JSON array) or PATHS_CSV (comma-separated).'); + process.exit(2); + } + + const result = filterPaths(paths); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +module.exports = { + filterPaths +}; diff --git a/.github/scripts/pr-context-graphql.js b/.github/scripts/pr-context-graphql.js index 8228f4c0a..46e07e2e8 100644 --- a/.github/scripts/pr-context-graphql.js +++ b/.github/scripts/pr-context-graphql.js @@ -134,6 +134,9 @@ query PRBasic($owner: String!, $repo: String!, $number: Int!) { } `; +const DEFAULT_IGNORED_PATH_PREFIXES = ['.agents/']; +const DEFAULT_IGNORED_PATH_PATTERNS = ['.agents/issue-*-ledger.yml']; + async function resolveGithubClient(github) { if (!github) { return github; @@ -148,6 +151,70 @@ async function resolveGithubClient(github) { } } +function parseCsv(value) { + if (!value) { + return []; + } + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeSlashes(value) { + return String(value || '').replace(/\\/g, '/').toLowerCase(); +} + +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 buildIgnoredPathMatchers(env = process.env) { + const prefixes = parseCsv(env.PR_CONTEXT_IGNORED_PATHS); + const patterns = parseCsv(env.PR_CONTEXT_IGNORED_PATTERNS); + const normalizedPrefixes = (prefixes.length ? prefixes : DEFAULT_IGNORED_PATH_PREFIXES) + .map((entry) => normalizeSlashes(entry)) + .filter(Boolean); + const normalizedPatterns = (patterns.length ? patterns : DEFAULT_IGNORED_PATH_PATTERNS) + .map((entry) => normalizeSlashes(entry)) + .filter(Boolean); + return { + prefixes: normalizedPrefixes, + patterns: normalizedPatterns.map(patternToRegex), + }; +} + +function shouldIgnorePath(filename, matchers) { + const normalized = normalizeSlashes(filename); + if (!normalized) { + return false; + } + if (matchers.prefixes.some((prefix) => normalized.startsWith(prefix))) { + return true; + } + return matchers.patterns.some((pattern) => pattern.test(normalized)); +} + /** * PAGINATION LIMITS: * The GraphQL queries above use fixed pagination limits: @@ -185,6 +252,21 @@ async function fetchPRContext(github, owner, repo, number) { } // Transform to a more usable format + const ignoredMatchers = buildIgnoredPathMatchers(process.env); + const rawFiles = pr.files?.nodes || []; + const filteredFiles = []; + const ignoredFiles = []; + + for (const file of rawFiles) { + if (shouldIgnorePath(file?.path, ignoredMatchers)) { + ignoredFiles.push(file); + } else { + filteredFiles.push(file); + } + } + + const ignoredCount = ignoredFiles.length; + return { id: pr.id, number: pr.number, @@ -204,9 +286,12 @@ async function fetchPRContext(github, owner, repo, number) { labelsDetailed: pr.labels?.nodes || [], files: { - total: pr.files?.totalCount || 0, - paths: (pr.files?.nodes || []).map(f => f.path), - detailed: pr.files?.nodes || [] + total: filteredFiles.length, + unfilteredTotal: pr.files?.totalCount || rawFiles.length, + ignored: ignoredCount, + ignoredPaths: ignoredFiles.map(f => f.path), + paths: filteredFiles.map(f => f.path), + detailed: filteredFiles }, reviews: (pr.reviews?.nodes || []).map(r => ({ @@ -397,6 +482,8 @@ module.exports = { PR_BASIC_QUERY, fetchPRContext, fetchPRBasic, + buildIgnoredPathMatchers, + shouldIgnorePath, serializeForOutput, deserializeFromOutput, createPRContextCache diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml index a6a468c9f..2ac7462c4 100644 --- a/.github/sync-manifest.yml +++ b/.github/sync-manifest.yml @@ -333,6 +333,9 @@ scripts: - source: .github/scripts/comment-dedupe.js description: "Deduplicates bot comments on issues/PRs" + - source: .github/scripts/bot-comment-dismiss.js + description: "Collects ignored bot review comments for auto-dismiss handling" + - source: .github/scripts/post_completion_comment.js description: "Posts completion comments on PRs" diff --git a/agents/codex-1385.md b/agents/codex-1385.md new file mode 100644 index 000000000..f0e95aec3 --- /dev/null +++ b/agents/codex-1385.md @@ -0,0 +1 @@ + diff --git a/agents/codex-1387.md b/agents/codex-1387.md new file mode 100644 index 000000000..241012541 --- /dev/null +++ b/agents/codex-1387.md @@ -0,0 +1,2 @@ + + diff --git a/templates/consumer-repo/.github/copilot-instructions.md b/templates/consumer-repo/.github/copilot-instructions.md index 93f394ee1..b5300bef9 100644 --- a/templates/consumer-repo/.github/copilot-instructions.md +++ b/templates/consumer-repo/.github/copilot-instructions.md @@ -15,6 +15,16 @@ --- +## Ignore Ledger Tracking Files + +Do not review or comment on ledger tracking files used for agent automation: +- `.agents/issue-*-ledger.yml` +- Any file under `.agents/` that is not application code + +These files are internal task trackers and should never receive Copilot review comments or suggestions. + +--- + ## Skill: GitHub Operations ### Authentication & PAT Usage diff --git a/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js b/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js new file mode 100644 index 000000000..ea3f46d55 --- /dev/null +++ b/templates/consumer-repo/.github/scripts/bot-comment-dismiss.js @@ -0,0 +1,245 @@ +'use strict'; + +/** + * Bot Comment Dismiss - Auto-dismiss ignored bot review comments + * + * Dismisses review comments from bots (Copilot, CodeRabbit, etc.) on files + * that should be ignored (e.g., .agents/ ledgers). + * + * API Wrapper: Use createTokenAwareRetry from github-api-with-retry.js + * to create the withRetry function passed to these functions. + * + * Example: + * const { createTokenAwareRetry } = require('./github-api-with-retry.js'); + * const { withRetry } = await createTokenAwareRetry({ github, core }); + * await autoDismissReviewComments({ github, withRetry, ... }); + */ + +const { buildIgnoredPathMatchers, shouldIgnorePath } = require('./pr-context-graphql'); + +function parseCsv(value) { + if (!value) { + return []; + } + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeAuthors(authors) { + return new Set((authors || []).map((author) => String(author || '').toLowerCase()).filter(Boolean)); +} + +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); +} + +function isBotAuthor(comment, botAuthors) { + if (!comment || !comment.user || !comment.user.login) { + return false; + } + return botAuthors.has(String(comment.user.login).toLowerCase()); +} + +function resolveCommentTimestamp(comment) { + if (!comment) { + return null; + } + return ( + comment.created_at || + comment.createdAt || + comment.updated_at || + comment.updatedAt || + null + ); +} + +function collectDismissable(comments, options = {}) { + const botAuthors = normalizeAuthors(options.botAuthors); + const matchers = buildMatchers({ + ignoredPaths: options.ignoredPaths, + ignoredPatterns: options.ignoredPatterns, + }); + const maxAgeSeconds = + typeof options.maxAgeSeconds === 'number' && Number.isFinite(options.maxAgeSeconds) + ? options.maxAgeSeconds + : null; + const now = typeof options.now === 'number' && Number.isFinite(options.now) ? options.now : Date.now(); + + const dismissable = []; + + for (const comment of comments || []) { + if (!isBotAuthor(comment, botAuthors)) { + continue; + } + const commentPath = comment.path || ''; + if (!shouldIgnorePath(commentPath, matchers)) { + continue; + } + if (maxAgeSeconds !== null) { + const timestamp = resolveCommentTimestamp(comment); + const createdAt = timestamp ? Date.parse(timestamp) : NaN; + if (!Number.isFinite(createdAt)) { + continue; + } + const ageSeconds = (now - createdAt) / 1000; + if (ageSeconds > maxAgeSeconds) { + continue; + } + } + dismissable.push({ + id: comment.id, + path: comment.path, + author: comment.user.login, + }); + } + + return dismissable; +} + +function formatDismissLog(entry) { + const path = entry.path || 'unknown-path'; + const author = entry.author || 'unknown-author'; + return `Auto-dismissed review comment ${entry.id} by ${author} in ${path}`; +} + +async function dismissReviewComments(options = {}) { + const github = options.github; + const dismissable = options.dismissable || []; + const owner = options.owner; + const repo = options.repo; + const withRetry = options.withRetry || ((fn) => fn()); + const logger = options.logger || console; + + if (!github || !github.rest || !github.rest.pulls) { + throw new Error('github client missing rest.pulls'); + } + if (!owner || !repo) { + throw new Error('owner and repo are required'); + } + + const dismissed = []; + const failed = []; + const logs = []; + + for (const entry of dismissable) { + try { + await withRetry(() => + github.rest.pulls.deleteReviewComment({ + owner, + repo, + comment_id: entry.id, + }) + ); + dismissed.push(entry); + const logLine = formatDismissLog(entry); + logs.push(logLine); + if (logger && typeof logger.info === 'function') { + logger.info(logLine); + } else if (logger && typeof logger.log === 'function') { + logger.log(logLine); + } + } catch (error) { + failed.push({ + ...entry, + error: error ? String(error.message || error) : 'unknown error', + }); + if (logger && typeof logger.warning === 'function') { + logger.warning(`Failed to dismiss review comment ${entry.id}: ${error?.message || error}`); + } else if (logger && typeof logger.warn === 'function') { + logger.warn(`Failed to dismiss review comment ${entry.id}: ${error?.message || error}`); + } + } + } + + return { dismissed, failed, logs }; +} + +async function autoDismissReviewComments(options = {}) { + const github = options.github; + const owner = options.owner; + const repo = options.repo; + const pullNumber = options.pullNumber; + const withRetry = options.withRetry || ((fn) => fn()); + const logger = options.logger || console; + + if (!github || !github.rest || !github.rest.pulls) { + throw new Error('github client missing rest.pulls'); + } + if (!owner || !repo) { + throw new Error('owner and repo are required'); + } + if (!pullNumber) { + throw new Error('pullNumber is required'); + } + + const response = await withRetry(() => + github.rest.pulls.listReviewComments({ + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }) + ); + const comments = Array.isArray(response?.data) ? response.data : response || []; + + const dismissable = collectDismissable(comments, { + ignoredPaths: options.ignoredPaths, + ignoredPatterns: options.ignoredPatterns, + botAuthors: options.botAuthors, + maxAgeSeconds: + typeof options.maxAgeSeconds === 'number' && Number.isFinite(options.maxAgeSeconds) + ? options.maxAgeSeconds + : null, + now: options.now, + }); + + const result = await dismissReviewComments({ + github, + owner, + repo, + dismissable, + withRetry, + logger, + }); + + return { dismissable, ...result }; +} + +function runCli(env = process.env) { + const comments = env.COMMENTS_JSON ? JSON.parse(env.COMMENTS_JSON) : []; + const ignoredPaths = parseCsv(env.IGNORED_PATHS); + const ignoredPatterns = parseCsv(env.IGNORED_PATTERNS); + const botAuthors = parseCsv(env.BOT_AUTHORS); + const maxAgeSeconds = env.MAX_AGE_SECONDS ? Number(env.MAX_AGE_SECONDS) : 30; + const now = env.NOW_EPOCH_MS ? Number(env.NOW_EPOCH_MS) : undefined; + + const dismissable = collectDismissable(comments, { + ignoredPaths, + ignoredPatterns, + botAuthors, + maxAgeSeconds: Number.isFinite(maxAgeSeconds) ? maxAgeSeconds : null, + now: Number.isFinite(now) ? now : undefined, + }); + const logs = dismissable.map((entry) => formatDismissLog(entry)); + + return { dismissable, logs }; +} + +if (require.main === module) { + const result = runCli(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +module.exports = { + collectDismissable, + autoDismissReviewComments, + dismissReviewComments, + formatDismissLog, + runCli, +}; diff --git a/templates/consumer-repo/.github/scripts/pr-context-graphql.js b/templates/consumer-repo/.github/scripts/pr-context-graphql.js index 8228f4c0a..46e07e2e8 100644 --- a/templates/consumer-repo/.github/scripts/pr-context-graphql.js +++ b/templates/consumer-repo/.github/scripts/pr-context-graphql.js @@ -134,6 +134,9 @@ query PRBasic($owner: String!, $repo: String!, $number: Int!) { } `; +const DEFAULT_IGNORED_PATH_PREFIXES = ['.agents/']; +const DEFAULT_IGNORED_PATH_PATTERNS = ['.agents/issue-*-ledger.yml']; + async function resolveGithubClient(github) { if (!github) { return github; @@ -148,6 +151,70 @@ async function resolveGithubClient(github) { } } +function parseCsv(value) { + if (!value) { + return []; + } + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeSlashes(value) { + return String(value || '').replace(/\\/g, '/').toLowerCase(); +} + +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 buildIgnoredPathMatchers(env = process.env) { + const prefixes = parseCsv(env.PR_CONTEXT_IGNORED_PATHS); + const patterns = parseCsv(env.PR_CONTEXT_IGNORED_PATTERNS); + const normalizedPrefixes = (prefixes.length ? prefixes : DEFAULT_IGNORED_PATH_PREFIXES) + .map((entry) => normalizeSlashes(entry)) + .filter(Boolean); + const normalizedPatterns = (patterns.length ? patterns : DEFAULT_IGNORED_PATH_PATTERNS) + .map((entry) => normalizeSlashes(entry)) + .filter(Boolean); + return { + prefixes: normalizedPrefixes, + patterns: normalizedPatterns.map(patternToRegex), + }; +} + +function shouldIgnorePath(filename, matchers) { + const normalized = normalizeSlashes(filename); + if (!normalized) { + return false; + } + if (matchers.prefixes.some((prefix) => normalized.startsWith(prefix))) { + return true; + } + return matchers.patterns.some((pattern) => pattern.test(normalized)); +} + /** * PAGINATION LIMITS: * The GraphQL queries above use fixed pagination limits: @@ -185,6 +252,21 @@ async function fetchPRContext(github, owner, repo, number) { } // Transform to a more usable format + const ignoredMatchers = buildIgnoredPathMatchers(process.env); + const rawFiles = pr.files?.nodes || []; + const filteredFiles = []; + const ignoredFiles = []; + + for (const file of rawFiles) { + if (shouldIgnorePath(file?.path, ignoredMatchers)) { + ignoredFiles.push(file); + } else { + filteredFiles.push(file); + } + } + + const ignoredCount = ignoredFiles.length; + return { id: pr.id, number: pr.number, @@ -204,9 +286,12 @@ async function fetchPRContext(github, owner, repo, number) { labelsDetailed: pr.labels?.nodes || [], files: { - total: pr.files?.totalCount || 0, - paths: (pr.files?.nodes || []).map(f => f.path), - detailed: pr.files?.nodes || [] + total: filteredFiles.length, + unfilteredTotal: pr.files?.totalCount || rawFiles.length, + ignored: ignoredCount, + ignoredPaths: ignoredFiles.map(f => f.path), + paths: filteredFiles.map(f => f.path), + detailed: filteredFiles }, reviews: (pr.reviews?.nodes || []).map(r => ({ @@ -397,6 +482,8 @@ module.exports = { PR_BASIC_QUERY, fetchPRContext, fetchPRBasic, + buildIgnoredPathMatchers, + shouldIgnorePath, serializeForOutput, deserializeFromOutput, createPRContextCache diff --git a/templates/consumer-repo/.github/workflows/agents-bot-comment-handler.yml b/templates/consumer-repo/.github/workflows/agents-bot-comment-handler.yml index ff216e9a0..43344be68 100644 --- a/templates/consumer-repo/.github/workflows/agents-bot-comment-handler.yml +++ b/templates/consumer-repo/.github/workflows/agents-bot-comment-handler.yml @@ -188,6 +188,159 @@ jobs: core.setOutput('pr_number', prNumber || ''); core.setOutput('should_run', shouldRun ? 'true' : 'false'); + # Dismiss ignored-path bot reviews to prevent noisy inline comments + dismiss_ignored: + name: Dismiss ignored bot reviews + needs: resolve + if: vars.USE_CONSOLIDATED_WORKFLOWS != 'true' && needs.resolve.outputs.should_run == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + .github/actions/setup-api-client + .github/scripts/github-api-with-retry.js + .github/scripts/token_load_balancer.js + sparse-checkout-cone-mode: false + + - name: Setup API client + uses: ./.github/actions/setup-api-client + with: + secrets: ${{ toJSON(secrets) }} + github_token: ${{ github.token }} + + - name: Dismiss ignored-path bot reviews + uses: actions/github-script@v8 + env: + BOT_AUTHORS: 'Copilot,copilot[bot],github-actions[bot],coderabbitai[bot],chatgpt-codex-connector[bot]' + IGNORED_PATHS: '.agents/,scripts/langchain/prompts/,docs/' + with: + script: | + const prNumber = parseInt('${{ needs.resolve.outputs.pr_number }}', 10); + if (!prNumber) { + console.log('No PR number resolved; skipping dismiss step.'); + return; + } + + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const { withRetry } = await createTokenAwareRetry({ github, core }); + + const botAuthors = (process.env.BOT_AUTHORS || '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + const ignoredPaths = (process.env.IGNORED_PATHS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + + if (botAuthors.length === 0 || ignoredPaths.length === 0) { + console.log('Missing bot authors or ignored paths; skipping dismiss step.'); + return; + } + + const [commentsResponse, reviewsResponse] = await Promise.all([ + withRetry((client) => client.rest.pulls.listReviewComments({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100, + })), + withRetry((client) => client.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100, + })), + ]); + + const comments = commentsResponse.data || []; + const reviews = reviewsResponse.data || []; + const reviewsById = new Map(reviews.map((review) => [review.id, review])); + const commentsByReview = new Map(); + + for (const comment of comments) { + const reviewId = comment.pull_request_review_id; + if (!reviewId) { + continue; + } + + const entry = commentsByReview.get(reviewId) || { + total: 0, + ignored: 0, + paths: [], + botLogins: new Set(), + ignoredComments: [], + }; + + entry.total += 1; + const commentPath = comment.path || ''; + entry.paths.push(commentPath); + + if (ignoredPaths.some((prefix) => commentPath.startsWith(prefix))) { + entry.ignored += 1; + const login = (comment.user?.login || '').toLowerCase(); + if (login) { + entry.botLogins.add(login); + } + entry.ignoredComments.push({ + id: comment.id, + path: commentPath, + login: comment.user?.login || 'unknown', + }); + console.log( + `Auto-dismiss candidate: comment=${comment.id} bot=${comment.user?.login} path=${commentPath}` + ); + } + + commentsByReview.set(reviewId, entry); + } + + let dismissed = 0; + for (const [reviewId, review] of reviewsById) { + const login = (review.user?.login || '').toLowerCase(); + if (!botAuthors.includes(login)) { + continue; + } + + const entry = commentsByReview.get(reviewId); + if (!entry || entry.total === 0) { + continue; + } + + if (entry.ignored !== entry.total) { + continue; + } + + const uniquePaths = Array.from(new Set(entry.paths)).filter(Boolean); + for (const ignored of entry.ignoredComments) { + console.log( + `Auto-dismissed review comment: comment=${ignored.id} bot=${ignored.login} path=${ignored.path}` + ); + } + const message = [ + 'Auto-dismissed bot review: all comments target ignored paths.', + `Ignored paths: ${ignoredPaths.join(', ')}`, + `Review paths: ${uniquePaths.join(', ') || '(none)'}`, + ].join('\n'); + + await withRetry((client) => client.rest.pulls.dismissReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + review_id: reviewId, + message, + })); + + dismissed += 1; + console.log( + `Dismissed review ${reviewId} from ${review.user?.login} for paths: ${uniquePaths.join(', ')}` + ); + } + + console.log(`Dismissed ${dismissed} bot review(s) targeting ignored paths.`); + # Call the reusable workflow handle: name: Handle bot comments diff --git a/tests/workflows/test_bot_comment_handler.py b/tests/workflows/test_bot_comment_handler.py new file mode 100644 index 000000000..28a8ff496 --- /dev/null +++ b/tests/workflows/test_bot_comment_handler.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +def _load_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def test_reusable_bot_comment_handler_ignores_agents_paths() -> None: + workflow = _load_yaml(ROOT / ".github/workflows/reusable-bot-comment-handler.yml") + triggers = workflow.get("on") or workflow.get(True) or {} + inputs = triggers.get("workflow_call", {}).get("inputs", {}) + + ignored_paths = inputs.get("ignored_paths", {}).get("default") + assert ignored_paths is not None + assert ".agents/" in ignored_paths.split(",") + + bot_authors = inputs.get("bot_authors", {}).get("default", "") + assert "chatgpt-codex-connector[bot]" in bot_authors + + +def test_template_bot_comment_handler_passes_agents_ignore() -> None: + workflow = _load_yaml( + ROOT / "templates/consumer-repo/.github/workflows/agents-bot-comment-handler.yml" + ) + handle_job = workflow.get("jobs", {}).get("handle", {}) + inputs = handle_job.get("with", {}) + ignored_paths = inputs.get("ignored_paths", "") + + assert ".agents/" in ignored_paths.split(",") + + +def test_template_bot_comment_handler_dismisses_ignored_reviews() -> None: + workflow = _load_yaml( + ROOT / "templates/consumer-repo/.github/workflows/agents-bot-comment-handler.yml" + ) + dismiss_job = workflow.get("jobs", {}).get("dismiss_ignored", {}) + assert dismiss_job, "dismiss_ignored job is missing" + + steps = dismiss_job.get("steps", []) + github_script_steps = [ + step for step in steps if step.get("uses", "").startswith("actions/github-script") + ] + assert github_script_steps, "dismiss_ignored job should run actions/github-script" + + script = github_script_steps[-1].get("with", {}).get("script", "") + assert "dismissReview" in script