diff --git a/src/github/api/queries/github.ts b/src/github/api/queries/github.ts index 25395b9..2341a55 100644 --- a/src/github/api/queries/github.ts +++ b/src/github/api/queries/github.ts @@ -46,6 +46,8 @@ export const PR_QUERY = ` login } createdAt + updatedAt + lastEditedAt isMinimized } } @@ -59,6 +61,8 @@ export const PR_QUERY = ` body state submittedAt + updatedAt + lastEditedAt comments(first: 100) { nodes { id @@ -70,6 +74,8 @@ export const PR_QUERY = ` login } createdAt + updatedAt + lastEditedAt isMinimized } } @@ -100,6 +106,8 @@ export const ISSUE_QUERY = ` login } createdAt + updatedAt + lastEditedAt isMinimized } } diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index ace1b85..51072f8 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -1,6 +1,12 @@ import { execFileSync } from "child_process"; import type { Octokits } from "../api/client"; import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github"; +import { + isIssueCommentEvent, + isPullRequestReviewEvent, + isPullRequestReviewCommentEvent, + type ParsedGitHubContext, +} from "../context"; import type { GitHubComment, GitHubFile, @@ -13,12 +19,101 @@ import type { import type { CommentWithImages } from "../utils/image-downloader"; import { downloadCommentImages } from "../utils/image-downloader"; +/** + * 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; +} + +/** + * 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 + */ +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 + 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. + */ +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 + 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; + }); +} + type FetchDataParams = { octokits: Octokits; repository: string; prNumber: string; isPR: boolean; triggerUsername?: string; + triggerTime?: string; }; export type GitHubFileWithSHA = GitHubFile & { @@ -41,6 +136,7 @@ export async function fetchGitHubData({ prNumber, isPR, triggerUsername, + triggerTime, }: FetchDataParams): Promise { const [owner, repo] = repository.split("/"); if (!owner || !repo) { @@ -68,7 +164,10 @@ export async function fetchGitHubData({ const pullRequest = prResult.repository.pullRequest; contextData = pullRequest; changedFiles = pullRequest.files.nodes || []; - comments = pullRequest.comments?.nodes || []; + comments = filterCommentsToTriggerTime( + pullRequest.comments?.nodes || [], + triggerTime, + ); reviewData = pullRequest.reviews || []; console.log(`Successfully fetched PR #${prNumber} data`); @@ -88,7 +187,10 @@ export async function fetchGitHubData({ if (issueResult.repository.issue) { contextData = issueResult.repository.issue; - comments = contextData?.comments?.nodes || []; + comments = filterCommentsToTriggerTime( + contextData?.comments?.nodes || [], + triggerTime, + ); console.log(`Successfully fetched issue #${prNumber} data`); } else { @@ -141,25 +243,35 @@ export async function fetchGitHubData({ body: c.body, })); - const reviewBodies: CommentWithImages[] = - reviewData?.nodes - ?.filter((r) => r.body) - .map((r) => ({ - type: "review_body" as const, - id: r.databaseId, - pullNumber: prNumber, - body: r.body, - })) ?? []; + // Filter review bodies to trigger time + const filteredReviewBodies = reviewData?.nodes + ? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter( + (r) => r.body, + ) + : []; - const reviewComments: CommentWithImages[] = - reviewData?.nodes - ?.flatMap((r) => r.comments?.nodes ?? []) - .filter((c) => c.body && !c.isMinimized) - .map((c) => ({ - type: "review_comment" as const, - id: c.databaseId, - body: c.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 + 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 const mainBody: CommentWithImages[] = contextData.body diff --git a/src/github/types.ts b/src/github/types.ts index f31d841..41e0896 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -10,6 +10,8 @@ export type GitHubComment = { body: string; author: GitHubAuthor; createdAt: string; + updatedAt?: string; + lastEditedAt?: string; isMinimized?: boolean; }; @@ -41,6 +43,8 @@ export type GitHubReview = { body: string; state: string; submittedAt: string; + updatedAt?: string; + lastEditedAt?: string; comments: { nodes: GitHubReviewComment[]; }; diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 48c17a3..c8fc12a 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -6,7 +6,10 @@ import { createInitialComment } from "../../github/operations/comments/create-in import { setupBranch } from "../../github/operations/branch"; import { configureGitAuth } from "../../github/operations/git-config"; import { prepareMcpConfig } from "../../mcp/install-mcp-server"; -import { fetchGitHubData } from "../../github/data/fetcher"; +import { + fetchGitHubData, + extractTriggerTimestamp, +} from "../../github/data/fetcher"; import { createPrompt, generateDefaultPrompt } from "../../create-prompt"; import { isEntityContext } from "../../github/context"; import type { PreparedContext } from "../../create-prompt/types"; @@ -70,12 +73,15 @@ export const tagMode: Mode = { const commentData = await createInitialComment(octokit.rest, context); const commentId = commentData.id; + const triggerTime = extractTriggerTimestamp(context); + const githubData = await fetchGitHubData({ octokits: octokit, repository: `${context.repository.owner}/${context.repository.repo}`, prNumber: context.entityNumber.toString(), isPR: context.isPR, triggerUsername: context.actor, + triggerTime, }); // Setup branch