mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-28 02:42:25 +08:00
- 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.
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
import { execFileSync } from "child_process";
|
|
import type { Octokits } from "../api/client";
|
|
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
|
import {
|
|
isIssueCommentEvent,
|
|
isIssuesEvent,
|
|
isPullRequestEvent,
|
|
isPullRequestReviewEvent,
|
|
isPullRequestReviewCommentEvent,
|
|
type ParsedGitHubContext,
|
|
} from "../context";
|
|
import type {
|
|
GitHubComment,
|
|
GitHubFile,
|
|
GitHubIssue,
|
|
GitHubPullRequest,
|
|
GitHubReview,
|
|
IssueQueryResponse,
|
|
PullRequestQueryResponse,
|
|
} 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.
|
|
* This timestamp represents when the triggering comment/review/event was created.
|
|
*
|
|
* @param context - Parsed GitHub context from webhook
|
|
* @returns ISO timestamp string or undefined if not available
|
|
*/
|
|
export function extractTriggerTimestamp(
|
|
context: ParsedGitHubContext,
|
|
): string | undefined {
|
|
if (isIssueCommentEvent(context)) {
|
|
return context.payload.comment.created_at || undefined;
|
|
} else if (isPullRequestReviewEvent(context)) {
|
|
return context.payload.review.submitted_at || undefined;
|
|
} else if (isPullRequestReviewCommentEvent(context)) {
|
|
return context.payload.comment.created_at || undefined;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extracts the original title from the GitHub webhook payload.
|
|
* This is the title as it existed when the trigger event occurred.
|
|
*
|
|
* @param context - Parsed GitHub context from webhook
|
|
* @returns The original title string or undefined if not available
|
|
*/
|
|
export function extractOriginalTitle(
|
|
context: ParsedGitHubContext,
|
|
): string | undefined {
|
|
if (isIssueCommentEvent(context)) {
|
|
return context.payload.issue?.title;
|
|
} else if (isPullRequestEvent(context)) {
|
|
return context.payload.pull_request?.title;
|
|
} else if (isPullRequestReviewEvent(context)) {
|
|
return context.payload.pull_request?.title;
|
|
} else if (isPullRequestReviewCommentEvent(context)) {
|
|
return context.payload.pull_request?.title;
|
|
} else if (isIssuesEvent(context)) {
|
|
return context.payload.issue?.title;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Filters comments to only include those that existed in their final state before the trigger time.
|
|
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
|
*
|
|
* @param comments - Array of GitHub comments to filter
|
|
* @param triggerTime - ISO timestamp of when the trigger comment was created
|
|
* @returns Filtered array of comments that were created and last edited before trigger time
|
|
*/
|
|
export function filterCommentsToTriggerTime<
|
|
T extends { createdAt: string; updatedAt?: string; lastEditedAt?: string },
|
|
>(comments: T[], triggerTime: string | undefined): T[] {
|
|
if (!triggerTime) return comments;
|
|
|
|
const triggerTimestamp = new Date(triggerTime).getTime();
|
|
|
|
return comments.filter((comment) => {
|
|
// Comment must have been created before trigger (not at or after)
|
|
const createdTimestamp = new Date(comment.createdAt).getTime();
|
|
if (createdTimestamp >= triggerTimestamp) {
|
|
return false;
|
|
}
|
|
|
|
// If comment has been edited, the most recent edit must have occurred before trigger
|
|
// Use lastEditedAt if available, otherwise fall back to updatedAt
|
|
const lastEditTime = comment.lastEditedAt || comment.updatedAt;
|
|
if (lastEditTime) {
|
|
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
|
if (lastEditTimestamp >= triggerTimestamp) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filters reviews to only include those that existed in their final state before the trigger time.
|
|
* Similar to filterCommentsToTriggerTime but for GitHubReview objects which use submittedAt instead of createdAt.
|
|
*/
|
|
export function filterReviewsToTriggerTime<
|
|
T extends { submittedAt: string; updatedAt?: string; lastEditedAt?: string },
|
|
>(reviews: T[], triggerTime: string | undefined): T[] {
|
|
if (!triggerTime) return reviews;
|
|
|
|
const triggerTimestamp = new Date(triggerTime).getTime();
|
|
|
|
return reviews.filter((review) => {
|
|
// Review must have been submitted before trigger (not at or after)
|
|
const submittedTimestamp = new Date(review.submittedAt).getTime();
|
|
if (submittedTimestamp >= triggerTimestamp) {
|
|
return false;
|
|
}
|
|
|
|
// If review has been edited, the most recent edit must have occurred before trigger
|
|
const lastEditTime = review.lastEditedAt || review.updatedAt;
|
|
if (lastEditTime) {
|
|
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
|
if (lastEditTimestamp >= triggerTimestamp) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if the issue/PR body was edited after the trigger time.
|
|
* This prevents a race condition where an attacker could edit the issue/PR body
|
|
* between when an authorized user triggered Claude and when Claude processes the request.
|
|
*
|
|
* @param contextData - The PR or issue data containing body and edit timestamps
|
|
* @param triggerTime - ISO timestamp of when the trigger event occurred
|
|
* @returns true if the body is safe to use, false if it was edited after trigger
|
|
*/
|
|
export function isBodySafeToUse(
|
|
contextData: { createdAt: string; updatedAt?: string; lastEditedAt?: string },
|
|
triggerTime: string | undefined,
|
|
): boolean {
|
|
// If no trigger time is available, we can't validate - allow the body
|
|
// This maintains backwards compatibility for triggers that don't have timestamps
|
|
if (!triggerTime) return true;
|
|
|
|
const triggerTimestamp = new Date(triggerTime).getTime();
|
|
|
|
// Check if the body was edited after the trigger
|
|
// Use lastEditedAt if available (more accurate for body edits), otherwise fall back to updatedAt
|
|
const lastEditTime = contextData.lastEditedAt || contextData.updatedAt;
|
|
if (lastEditTime) {
|
|
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
|
if (lastEditTimestamp >= triggerTimestamp) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
prNumber: string;
|
|
isPR: boolean;
|
|
triggerUsername?: string;
|
|
triggerTime?: string;
|
|
originalTitle?: string;
|
|
includeCommentsByActor?: string;
|
|
excludeCommentsByActor?: string;
|
|
};
|
|
|
|
export type GitHubFileWithSHA = GitHubFile & {
|
|
sha: string;
|
|
};
|
|
|
|
export type FetchDataResult = {
|
|
contextData: GitHubPullRequest | GitHubIssue;
|
|
comments: GitHubComment[];
|
|
changedFiles: GitHubFile[];
|
|
changedFilesWithSHA: GitHubFileWithSHA[];
|
|
reviewData: { nodes: GitHubReview[] } | null;
|
|
imageUrlMap: Map<string, string>;
|
|
triggerDisplayName?: string | null;
|
|
};
|
|
|
|
export async function fetchGitHubData({
|
|
octokits,
|
|
repository,
|
|
prNumber,
|
|
isPR,
|
|
triggerUsername,
|
|
triggerTime,
|
|
originalTitle,
|
|
includeCommentsByActor,
|
|
excludeCommentsByActor,
|
|
}: FetchDataParams): Promise<FetchDataResult> {
|
|
const [owner, repo] = repository.split("/");
|
|
if (!owner || !repo) {
|
|
throw new Error("Invalid repository format. Expected 'owner/repo'.");
|
|
}
|
|
|
|
let contextData: GitHubPullRequest | GitHubIssue | null = null;
|
|
let comments: GitHubComment[] = [];
|
|
let changedFiles: GitHubFile[] = [];
|
|
let reviewData: { nodes: GitHubReview[] } | null = null;
|
|
|
|
try {
|
|
if (isPR) {
|
|
// Fetch PR data with all comments and file information
|
|
const prResult = await octokits.graphql<PullRequestQueryResponse>(
|
|
PR_QUERY,
|
|
{
|
|
owner,
|
|
repo,
|
|
number: parseInt(prNumber),
|
|
},
|
|
);
|
|
|
|
if (prResult.repository.pullRequest) {
|
|
const pullRequest = prResult.repository.pullRequest;
|
|
contextData = pullRequest;
|
|
changedFiles = pullRequest.files.nodes || [];
|
|
comments = filterCommentsByActor(
|
|
filterCommentsToTriggerTime(
|
|
pullRequest.comments?.nodes || [],
|
|
triggerTime,
|
|
),
|
|
includeCommentsByActor,
|
|
excludeCommentsByActor,
|
|
);
|
|
reviewData = pullRequest.reviews || [];
|
|
|
|
console.log(`Successfully fetched PR #${prNumber} data`);
|
|
} else {
|
|
throw new Error(`PR #${prNumber} not found`);
|
|
}
|
|
} else {
|
|
// Fetch issue data
|
|
const issueResult = await octokits.graphql<IssueQueryResponse>(
|
|
ISSUE_QUERY,
|
|
{
|
|
owner,
|
|
repo,
|
|
number: parseInt(prNumber),
|
|
},
|
|
);
|
|
|
|
if (issueResult.repository.issue) {
|
|
contextData = issueResult.repository.issue;
|
|
comments = filterCommentsByActor(
|
|
filterCommentsToTriggerTime(
|
|
contextData?.comments?.nodes || [],
|
|
triggerTime,
|
|
),
|
|
includeCommentsByActor,
|
|
excludeCommentsByActor,
|
|
);
|
|
|
|
console.log(`Successfully fetched issue #${prNumber} data`);
|
|
} else {
|
|
throw new Error(`Issue #${prNumber} not found`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to fetch ${isPR ? "PR" : "issue"} data:`, error);
|
|
throw new Error(`Failed to fetch ${isPR ? "PR" : "issue"} data`);
|
|
}
|
|
|
|
// Compute SHAs for changed files
|
|
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
|
|
if (isPR && changedFiles.length > 0) {
|
|
changedFilesWithSHA = changedFiles.map((file) => {
|
|
// Don't compute SHA for deleted files
|
|
if (file.changeType === "DELETED") {
|
|
return {
|
|
...file,
|
|
sha: "deleted",
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Use git hash-object to compute the SHA for the current file content
|
|
const sha = execFileSync("git", ["hash-object", file.path], {
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
return {
|
|
...file,
|
|
sha,
|
|
};
|
|
} catch (error) {
|
|
console.warn(`Failed to compute SHA for ${file.path}:`, error);
|
|
// Return original file without SHA if computation fails
|
|
return {
|
|
...file,
|
|
sha: "unknown",
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// Prepare all comments for image processing
|
|
const issueComments: CommentWithImages[] = comments
|
|
.filter((c) => c.body && !c.isMinimized)
|
|
.map((c) => ({
|
|
type: "issue_comment" as const,
|
|
id: c.databaseId,
|
|
body: c.body,
|
|
}));
|
|
|
|
// Filter review bodies to trigger time
|
|
const filteredReviewBodies = reviewData?.nodes
|
|
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
|
|
(r) => r.body,
|
|
)
|
|
: [];
|
|
|
|
const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({
|
|
type: "review_body" as const,
|
|
id: r.databaseId,
|
|
pullNumber: prNumber,
|
|
body: r.body,
|
|
}));
|
|
|
|
// 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(
|
|
allReviewComments,
|
|
triggerTime,
|
|
);
|
|
|
|
const reviewComments: CommentWithImages[] = filteredReviewComments
|
|
.filter((c) => c.body && !c.isMinimized)
|
|
.map((c) => ({
|
|
type: "review_comment" as const,
|
|
id: c.databaseId,
|
|
body: c.body,
|
|
}));
|
|
|
|
// Add the main issue/PR body if it has content and wasn't edited after trigger
|
|
// This prevents a TOCTOU race condition where an attacker could edit the body
|
|
// between when an authorized user triggered Claude and when Claude processes the request
|
|
let mainBody: CommentWithImages[] = [];
|
|
if (contextData.body) {
|
|
if (isBodySafeToUse(contextData, triggerTime)) {
|
|
mainBody = [
|
|
{
|
|
...(isPR
|
|
? {
|
|
type: "pr_body" as const,
|
|
pullNumber: prNumber,
|
|
body: contextData.body,
|
|
}
|
|
: {
|
|
type: "issue_body" as const,
|
|
issueNumber: prNumber,
|
|
body: contextData.body,
|
|
}),
|
|
},
|
|
];
|
|
} else {
|
|
console.warn(
|
|
`Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` +
|
|
`Excluding body content to prevent potential injection attacks.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const allComments = [
|
|
...mainBody,
|
|
...issueComments,
|
|
...reviewBodies,
|
|
...reviewComments,
|
|
];
|
|
|
|
const imageUrlMap = await downloadCommentImages(
|
|
octokits,
|
|
owner,
|
|
repo,
|
|
allComments,
|
|
);
|
|
|
|
// Fetch trigger user display name if username is provided
|
|
let triggerDisplayName: string | null | undefined;
|
|
if (triggerUsername) {
|
|
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
|
}
|
|
|
|
// Use the original title from the webhook payload if provided
|
|
if (originalTitle !== undefined) {
|
|
contextData.title = originalTitle;
|
|
}
|
|
|
|
return {
|
|
contextData,
|
|
comments,
|
|
changedFiles,
|
|
changedFilesWithSHA,
|
|
reviewData,
|
|
imageUrlMap,
|
|
triggerDisplayName,
|
|
};
|
|
}
|
|
|
|
export type UserQueryResponse = {
|
|
user: {
|
|
name: string | null;
|
|
};
|
|
};
|
|
|
|
export async function fetchUserDisplayName(
|
|
octokits: Octokits,
|
|
login: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const result = await octokits.graphql<UserQueryResponse>(USER_QUERY, {
|
|
login,
|
|
});
|
|
return result.user.name;
|
|
} catch (error) {
|
|
console.warn(`Failed to fetch user display name for ${login}:`, error);
|
|
return null;
|
|
}
|
|
}
|