feat: add actor-based comment filtering to GitHub data fetching (#812)

- Introduced `include_comments_by_actor` and `exclude_comments_by_actor` inputs in action.yml to allow filtering of comments based on actor usernames.
- Updated context parsing to handle new input fields.
- Implemented `filterCommentsByActor` function to filter comments according to specified inclusion and exclusion patterns.
- Modified `fetchGitHubData` to apply actor filters when retrieving comments from pull requests and issues.
- Added comprehensive tests for the new filtering functionality.

This enhancement provides more control over which comments are processed based on the actor, improving the flexibility of the workflow.
This commit is contained in:
Rani Halabi
2026-01-27 17:48:10 +02:00
committed by GitHub
parent 231bd75b71
commit fe72061e16
11 changed files with 444 additions and 10 deletions

View File

@@ -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 }}

View File

@@ -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 ?? "",
},
};

View File

@@ -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<T extends { author: { login: string } }>(
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<FetchDataResult> {
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(
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(
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(

View File

@@ -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;
}

View File

@@ -89,6 +89,8 @@ export const tagMode: Mode = {
triggerUsername: context.actor,
triggerTime,
originalTitle,
includeCommentsByActor: context.inputs.includeCommentsByActor,
excludeCommentsByActor: context.inputs.excludeCommentsByActor,
});
// Setup branch

172
test/actor-filter.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -39,6 +39,8 @@ describe("prepareMcpConfig", () => {
allowedNonWriteUsers: "",
trackProgress: false,
includeFixLinks: true,
includeCommentsByActor: "",
excludeCommentsByActor: "",
},
};

View File

@@ -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 };

View File

@@ -27,6 +27,8 @@ describe("detectMode with enhanced routing", () => {
allowedNonWriteUsers: "",
trackProgress: false,
includeFixLinks: true,
includeCommentsByActor: "",
excludeCommentsByActor: "",
},
};

View File

@@ -75,6 +75,8 @@ describe("checkWritePermissions", () => {
allowedNonWriteUsers: "",
trackProgress: false,
includeFixLinks: true,
includeCommentsByActor: "",
excludeCommentsByActor: "",
},
});