diff --git a/action.yml b/action.yml index cd2f75172..43a5b309a 100644 --- a/action.yml +++ b/action.yml @@ -35,6 +35,14 @@ inputs: description: "Comma-separated list of usernames to allow without write permissions, or '*' to allow all users. Only works when github_token input is provided. WARNING: Use with extreme caution - this bypasses security checks and should only be used for workflows with very limited permissions (e.g., issue labeling)." required: false default: "" + include_comments_by_actor: + description: "Comma-separated list of actor usernames to INCLUDE in comments. Supports wildcards: '*[bot]' matches all bots, 'dependabot[bot]' matches specific bot. Empty (default) includes all actors." + required: false + default: "" + exclude_comments_by_actor: + description: "Comma-separated list of actor usernames to EXCLUDE from comments. Supports wildcards: '*[bot]' matches all bots, 'renovate[bot]' matches specific bot. Empty (default) excludes none. If actor is in both lists, exclusion takes priority." + required: false + default: "" # Claude Code configuration prompt: @@ -186,6 +194,8 @@ runs: OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} ALLOWED_BOTS: ${{ inputs.allowed_bots }} ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }} + INCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.include_comments_by_actor }} + EXCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.exclude_comments_by_actor }} GITHUB_RUN_ID: ${{ github.run_id }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} diff --git a/src/github/context.ts b/src/github/context.ts index 811950f62..936e70e91 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -98,6 +98,8 @@ type BaseContext = { allowedNonWriteUsers: string; trackProgress: boolean; includeFixLinks: boolean; + includeCommentsByActor: string; + excludeCommentsByActor: string; }; }; @@ -156,6 +158,8 @@ export function parseGitHubContext(): GitHubContext { allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "", trackProgress: process.env.TRACK_PROGRESS === "true", includeFixLinks: process.env.INCLUDE_FIX_LINKS === "true", + includeCommentsByActor: process.env.INCLUDE_COMMENTS_BY_ACTOR ?? "", + excludeCommentsByActor: process.env.EXCLUDE_COMMENTS_BY_ACTOR ?? "", }, }; diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index b59964da0..fb344ac3a 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -20,6 +20,10 @@ import type { } from "../types"; import type { CommentWithImages } from "../utils/image-downloader"; import { downloadCommentImages } from "../utils/image-downloader"; +import { + parseActorFilter, + shouldIncludeCommentByActor, +} from "../utils/actor-filter"; /** * Extracts the trigger timestamp from the GitHub webhook payload. @@ -166,6 +170,35 @@ export function isBodySafeToUse( return true; } +/** + * Filters comments by actor username based on include/exclude patterns + * @param comments - Array of comments to filter + * @param includeActors - Comma-separated actors to include + * @param excludeActors - Comma-separated actors to exclude + * @returns Filtered array of comments + */ +export function filterCommentsByActor( + comments: T[], + includeActors: string = "", + excludeActors: string = "", +): T[] { + const includeParsed = parseActorFilter(includeActors); + const excludeParsed = parseActorFilter(excludeActors); + + // No filters = return all + if (includeParsed.length === 0 && excludeParsed.length === 0) { + return comments; + } + + return comments.filter((comment) => + shouldIncludeCommentByActor( + comment.author.login, + includeParsed, + excludeParsed, + ), + ); +} + type FetchDataParams = { octokits: Octokits; repository: string; @@ -174,6 +207,8 @@ type FetchDataParams = { triggerUsername?: string; triggerTime?: string; originalTitle?: string; + includeCommentsByActor?: string; + excludeCommentsByActor?: string; }; export type GitHubFileWithSHA = GitHubFile & { @@ -198,6 +233,8 @@ export async function fetchGitHubData({ triggerUsername, triggerTime, originalTitle, + includeCommentsByActor, + excludeCommentsByActor, }: FetchDataParams): Promise { const [owner, repo] = repository.split("/"); if (!owner || !repo) { @@ -225,9 +262,13 @@ export async function fetchGitHubData({ const pullRequest = prResult.repository.pullRequest; contextData = pullRequest; changedFiles = pullRequest.files.nodes || []; - comments = filterCommentsToTriggerTime( - pullRequest.comments?.nodes || [], - triggerTime, + comments = filterCommentsByActor( + filterCommentsToTriggerTime( + pullRequest.comments?.nodes || [], + triggerTime, + ), + includeCommentsByActor, + excludeCommentsByActor, ); reviewData = pullRequest.reviews || []; @@ -248,9 +289,13 @@ export async function fetchGitHubData({ if (issueResult.repository.issue) { contextData = issueResult.repository.issue; - comments = filterCommentsToTriggerTime( - contextData?.comments?.nodes || [], - triggerTime, + comments = filterCommentsByActor( + filterCommentsToTriggerTime( + contextData?.comments?.nodes || [], + triggerTime, + ), + includeCommentsByActor, + excludeCommentsByActor, ); console.log(`Successfully fetched issue #${prNumber} data`); @@ -318,7 +363,27 @@ export async function fetchGitHubData({ body: r.body, })); - // Filter review comments to trigger time + // Filter review comments to trigger time and by actor + if (reviewData && reviewData.nodes) { + // Filter reviews by actor + reviewData.nodes = filterCommentsByActor( + reviewData.nodes, + includeCommentsByActor, + excludeCommentsByActor, + ); + + // Also filter inline review comments within each review + reviewData.nodes.forEach((review) => { + if (review.comments?.nodes) { + review.comments.nodes = filterCommentsByActor( + review.comments.nodes, + includeCommentsByActor, + excludeCommentsByActor, + ); + } + }); + } + const allReviewComments = reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? []; const filteredReviewComments = filterCommentsToTriggerTime( diff --git a/src/github/utils/actor-filter.ts b/src/github/utils/actor-filter.ts new file mode 100644 index 000000000..2aebae705 --- /dev/null +++ b/src/github/utils/actor-filter.ts @@ -0,0 +1,65 @@ +/** + * Parses actor filter string into array of patterns + * @param filterString - Comma-separated actor names (e.g., "user1,user2,*[bot]") + * @returns Array of actor patterns + */ +export function parseActorFilter(filterString: string): string[] { + if (!filterString.trim()) return []; + return filterString + .split(",") + .map((actor) => actor.trim()) + .filter((actor) => actor.length > 0); +} + +/** + * Checks if an actor matches a pattern + * Supports wildcards: "*[bot]" matches all bots, "dependabot[bot]" matches specific + * @param actor - Actor username to check + * @param pattern - Pattern to match against + * @returns true if actor matches pattern + */ +export function actorMatchesPattern(actor: string, pattern: string): boolean { + // Exact match + if (actor === pattern) return true; + + // Wildcard bot pattern: "*[bot]" matches any username ending with [bot] + if (pattern === "*[bot]" && actor.endsWith("[bot]")) return true; + + // No match + return false; +} + +/** + * Determines if a comment should be included based on actor filters + * @param actor - Comment author username + * @param includeActors - Array of actors to include (empty = include all) + * @param excludeActors - Array of actors to exclude (empty = exclude none) + * @returns true if comment should be included + */ +export function shouldIncludeCommentByActor( + actor: string, + includeActors: string[], + excludeActors: string[], +): boolean { + // Check exclusion first (exclusion takes priority) + if (excludeActors.length > 0) { + for (const pattern of excludeActors) { + if (actorMatchesPattern(actor, pattern)) { + return false; // Excluded + } + } + } + + // Check inclusion + if (includeActors.length > 0) { + for (const pattern of includeActors) { + if (actorMatchesPattern(actor, pattern)) { + return true; // Explicitly included + } + } + return false; // Not in include list + } + + // No filters or passed all checks + return true; +} diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 488bca362..4e7c2a8f7 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -89,6 +89,8 @@ export const tagMode: Mode = { triggerUsername: context.actor, triggerTime, originalTitle, + includeCommentsByActor: context.inputs.includeCommentsByActor, + excludeCommentsByActor: context.inputs.excludeCommentsByActor, }); // Setup branch diff --git a/test/actor-filter.test.ts b/test/actor-filter.test.ts new file mode 100644 index 000000000..e15cb04c3 --- /dev/null +++ b/test/actor-filter.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + parseActorFilter, + actorMatchesPattern, + shouldIncludeCommentByActor, +} from "../src/github/utils/actor-filter"; + +describe("parseActorFilter", () => { + test("parses comma-separated actors", () => { + expect(parseActorFilter("user1,user2,bot[bot]")).toEqual([ + "user1", + "user2", + "bot[bot]", + ]); + }); + + test("handles empty string", () => { + expect(parseActorFilter("")).toEqual([]); + }); + + test("handles whitespace-only string", () => { + expect(parseActorFilter(" ")).toEqual([]); + }); + + test("trims whitespace", () => { + expect(parseActorFilter(" user1 , user2 ")).toEqual(["user1", "user2"]); + }); + + test("filters out empty entries", () => { + expect(parseActorFilter("user1,,user2")).toEqual(["user1", "user2"]); + }); + + test("handles single actor", () => { + expect(parseActorFilter("user1")).toEqual(["user1"]); + }); + + test("handles wildcard bot pattern", () => { + expect(parseActorFilter("*[bot]")).toEqual(["*[bot]"]); + }); +}); + +describe("actorMatchesPattern", () => { + test("matches exact username", () => { + expect(actorMatchesPattern("john-doe", "john-doe")).toBe(true); + }); + + test("does not match different username", () => { + expect(actorMatchesPattern("john-doe", "jane-doe")).toBe(false); + }); + + test("matches wildcard bot pattern", () => { + expect(actorMatchesPattern("dependabot[bot]", "*[bot]")).toBe(true); + expect(actorMatchesPattern("renovate[bot]", "*[bot]")).toBe(true); + expect(actorMatchesPattern("github-actions[bot]", "*[bot]")).toBe(true); + }); + + test("does not match non-bot with wildcard", () => { + expect(actorMatchesPattern("john-doe", "*[bot]")).toBe(false); + expect(actorMatchesPattern("user-bot", "*[bot]")).toBe(false); + }); + + test("matches specific bot", () => { + expect(actorMatchesPattern("dependabot[bot]", "dependabot[bot]")).toBe( + true, + ); + expect(actorMatchesPattern("renovate[bot]", "renovate[bot]")).toBe(true); + }); + + test("does not match different specific bot", () => { + expect(actorMatchesPattern("dependabot[bot]", "renovate[bot]")).toBe(false); + }); + + test("is case sensitive", () => { + expect(actorMatchesPattern("User1", "user1")).toBe(false); + expect(actorMatchesPattern("user1", "User1")).toBe(false); + }); +}); + +describe("shouldIncludeCommentByActor", () => { + test("includes all when no filters", () => { + expect(shouldIncludeCommentByActor("user1", [], [])).toBe(true); + expect(shouldIncludeCommentByActor("bot[bot]", [], [])).toBe(true); + }); + + test("excludes when in exclude list", () => { + expect(shouldIncludeCommentByActor("bot[bot]", [], ["*[bot]"])).toBe(false); + expect(shouldIncludeCommentByActor("user1", [], ["user1"])).toBe(false); + }); + + test("includes when not in exclude list", () => { + expect(shouldIncludeCommentByActor("user1", [], ["user2"])).toBe(true); + expect(shouldIncludeCommentByActor("user1", [], ["*[bot]"])).toBe(true); + }); + + test("includes when in include list", () => { + expect(shouldIncludeCommentByActor("user1", ["user1", "user2"], [])).toBe( + true, + ); + expect(shouldIncludeCommentByActor("user2", ["user1", "user2"], [])).toBe( + true, + ); + }); + + test("excludes when not in include list", () => { + expect(shouldIncludeCommentByActor("user3", ["user1", "user2"], [])).toBe( + false, + ); + }); + + test("exclusion takes priority over inclusion", () => { + expect(shouldIncludeCommentByActor("user1", ["user1"], ["user1"])).toBe( + false, + ); + expect( + shouldIncludeCommentByActor("bot[bot]", ["*[bot]"], ["*[bot]"]), + ).toBe(false); + }); + + test("handles wildcard in include list", () => { + expect(shouldIncludeCommentByActor("dependabot[bot]", ["*[bot]"], [])).toBe( + true, + ); + expect(shouldIncludeCommentByActor("renovate[bot]", ["*[bot]"], [])).toBe( + true, + ); + expect(shouldIncludeCommentByActor("user1", ["*[bot]"], [])).toBe(false); + }); + + test("handles wildcard in exclude list", () => { + expect(shouldIncludeCommentByActor("dependabot[bot]", [], ["*[bot]"])).toBe( + false, + ); + expect(shouldIncludeCommentByActor("renovate[bot]", [], ["*[bot]"])).toBe( + false, + ); + expect(shouldIncludeCommentByActor("user1", [], ["*[bot]"])).toBe(true); + }); + + test("handles mixed include and exclude lists", () => { + // Include user1 and user2, but exclude user2 + expect( + shouldIncludeCommentByActor("user1", ["user1", "user2"], ["user2"]), + ).toBe(true); + expect( + shouldIncludeCommentByActor("user2", ["user1", "user2"], ["user2"]), + ).toBe(false); + expect( + shouldIncludeCommentByActor("user3", ["user1", "user2"], ["user2"]), + ).toBe(false); + }); + + test("handles complex bot filtering", () => { + // Include all bots but exclude dependabot + expect( + shouldIncludeCommentByActor( + "renovate[bot]", + ["*[bot]"], + ["dependabot[bot]"], + ), + ).toBe(true); + expect( + shouldIncludeCommentByActor( + "dependabot[bot]", + ["*[bot]"], + ["dependabot[bot]"], + ), + ).toBe(false); + expect( + shouldIncludeCommentByActor("user1", ["*[bot]"], ["dependabot[bot]"]), + ).toBe(false); + }); +}); diff --git a/test/data-fetcher.test.ts b/test/data-fetcher.test.ts index 13e0fca02..90359628b 100644 --- a/test/data-fetcher.test.ts +++ b/test/data-fetcher.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, jest } from "bun:test"; +import { describe, expect, it, jest, test } from "bun:test"; import { extractTriggerTimestamp, extractOriginalTitle, @@ -1100,3 +1100,101 @@ describe("fetchGitHubData integration with time filtering", () => { ); }); }); + +describe("filterCommentsByActor", () => { + test("filters out excluded actors", () => { + const comments = [ + { author: { login: "user1" }, body: "comment1" }, + { author: { login: "bot[bot]" }, body: "comment2" }, + { author: { login: "user2" }, body: "comment3" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "", "*[bot]"); + expect(filtered).toHaveLength(2); + expect(filtered.map((c: any) => c.author.login)).toEqual([ + "user1", + "user2", + ]); + }); + + test("includes only specified actors", () => { + const comments = [ + { author: { login: "user1" }, body: "comment1" }, + { author: { login: "user2" }, body: "comment2" }, + { author: { login: "user3" }, body: "comment3" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "user1,user2", ""); + expect(filtered).toHaveLength(2); + expect(filtered.map((c: any) => c.author.login)).toEqual([ + "user1", + "user2", + ]); + }); + + test("returns all when no filters", () => { + const comments = [ + { author: { login: "user1" }, body: "comment1" }, + { author: { login: "user2" }, body: "comment2" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "", ""); + expect(filtered).toHaveLength(2); + }); + + test("exclusion takes priority", () => { + const comments = [ + { author: { login: "user1" }, body: "comment1" }, + { author: { login: "user2" }, body: "comment2" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "user1,user2", "user1"); + expect(filtered).toHaveLength(1); + expect(filtered[0].author.login).toBe("user2"); + }); + + test("filters multiple bot types", () => { + const comments = [ + { author: { login: "user1" }, body: "comment1" }, + { author: { login: "dependabot[bot]" }, body: "comment2" }, + { author: { login: "renovate[bot]" }, body: "comment3" }, + { author: { login: "user2" }, body: "comment4" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "", "*[bot]"); + expect(filtered).toHaveLength(2); + expect(filtered.map((c: any) => c.author.login)).toEqual([ + "user1", + "user2", + ]); + }); + + test("filters specific bot only", () => { + const comments = [ + { author: { login: "dependabot[bot]" }, body: "comment1" }, + { author: { login: "renovate[bot]" }, body: "comment2" }, + { author: { login: "user1" }, body: "comment3" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "", "dependabot[bot]"); + expect(filtered).toHaveLength(2); + expect(filtered.map((c: any) => c.author.login)).toEqual([ + "renovate[bot]", + "user1", + ]); + }); + + test("handles empty comment array", () => { + const comments: any[] = []; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "user1", ""); + expect(filtered).toHaveLength(0); + }); +}); diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index a50d46f71..8de7b5ce1 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -39,6 +39,8 @@ describe("prepareMcpConfig", () => { allowedNonWriteUsers: "", trackProgress: false, includeFixLinks: true, + includeCommentsByActor: "", + excludeCommentsByActor: "", }, }; diff --git a/test/mockContext.ts b/test/mockContext.ts index 1a4983b40..19ab04473 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -27,6 +27,8 @@ const defaultInputs = { allowedNonWriteUsers: "", trackProgress: false, includeFixLinks: true, + includeCommentsByActor: "", + excludeCommentsByActor: "", }; const defaultRepository = { @@ -55,7 +57,12 @@ export const createMockContext = ( }; const mergedInputs = overrides.inputs - ? { ...defaultInputs, ...overrides.inputs } + ? { + ...defaultInputs, + ...overrides.inputs, + includeCommentsByActor: overrides.inputs.includeCommentsByActor ?? "", + excludeCommentsByActor: overrides.inputs.excludeCommentsByActor ?? "", + } : defaultInputs; return { ...baseContext, ...overrides, inputs: mergedInputs }; @@ -79,7 +86,12 @@ export const createMockAutomationContext = ( }; const mergedInputs = overrides.inputs - ? { ...defaultInputs, ...overrides.inputs } + ? { + ...defaultInputs, + ...overrides.inputs, + includeCommentsByActor: overrides.inputs.includeCommentsByActor ?? "", + excludeCommentsByActor: overrides.inputs.excludeCommentsByActor ?? "", + } : { ...defaultInputs }; return { ...baseContext, ...overrides, inputs: mergedInputs }; diff --git a/test/modes/detector.test.ts b/test/modes/detector.test.ts index c539b8038..c8a6c75ff 100644 --- a/test/modes/detector.test.ts +++ b/test/modes/detector.test.ts @@ -27,6 +27,8 @@ describe("detectMode with enhanced routing", () => { allowedNonWriteUsers: "", trackProgress: false, includeFixLinks: true, + includeCommentsByActor: "", + excludeCommentsByActor: "", }, }; diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 557f7caf1..cf2efdb0e 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -75,6 +75,8 @@ describe("checkWritePermissions", () => { allowedNonWriteUsers: "", trackProgress: false, includeFixLinks: true, + includeCommentsByActor: "", + excludeCommentsByActor: "", }, });