11import { execFileSync } from "child_process" ;
22import type { Octokits } from "../api/client" ;
33import { ISSUE_QUERY , PR_QUERY , USER_QUERY } from "../api/queries/github" ;
4+ import {
5+ isIssueCommentEvent ,
6+ isPullRequestReviewEvent ,
7+ isPullRequestReviewCommentEvent ,
8+ type ParsedGitHubContext ,
9+ } from "../context" ;
410import type {
511 GitHubComment ,
612 GitHubFile ,
@@ -13,12 +19,101 @@ import type {
1319import type { CommentWithImages } from "../utils/image-downloader" ;
1420import { downloadCommentImages } from "../utils/image-downloader" ;
1521
22+ /**
23+ * Extracts the trigger timestamp from the GitHub webhook payload.
24+ * This timestamp represents when the triggering comment/review/event was created.
25+ *
26+ * @param context - Parsed GitHub context from webhook
27+ * @returns ISO timestamp string or undefined if not available
28+ */
29+ export function extractTriggerTimestamp (
30+ context : ParsedGitHubContext ,
31+ ) : string | undefined {
32+ if ( isIssueCommentEvent ( context ) ) {
33+ return context . payload . comment . created_at || undefined ;
34+ } else if ( isPullRequestReviewEvent ( context ) ) {
35+ return context . payload . review . submitted_at || undefined ;
36+ } else if ( isPullRequestReviewCommentEvent ( context ) ) {
37+ return context . payload . comment . created_at || undefined ;
38+ }
39+
40+ return undefined ;
41+ }
42+
43+ /**
44+ * Filters comments to only include those that existed in their final state before the trigger time.
45+ * This prevents malicious actors from editing comments after the trigger to inject harmful content.
46+ *
47+ * @param comments - Array of GitHub comments to filter
48+ * @param triggerTime - ISO timestamp of when the trigger comment was created
49+ * @returns Filtered array of comments that were created and last edited before trigger time
50+ */
51+ export function filterCommentsToTriggerTime <
52+ T extends { createdAt : string ; updatedAt ?: string ; lastEditedAt ?: string } ,
53+ > ( comments : T [ ] , triggerTime : string | undefined ) : T [ ] {
54+ if ( ! triggerTime ) return comments ;
55+
56+ const triggerTimestamp = new Date ( triggerTime ) . getTime ( ) ;
57+
58+ return comments . filter ( ( comment ) => {
59+ // Comment must have been created before trigger (not at or after)
60+ const createdTimestamp = new Date ( comment . createdAt ) . getTime ( ) ;
61+ if ( createdTimestamp >= triggerTimestamp ) {
62+ return false ;
63+ }
64+
65+ // If comment has been edited, the most recent edit must have occurred before trigger
66+ // Use lastEditedAt if available, otherwise fall back to updatedAt
67+ const lastEditTime = comment . lastEditedAt || comment . updatedAt ;
68+ if ( lastEditTime ) {
69+ const lastEditTimestamp = new Date ( lastEditTime ) . getTime ( ) ;
70+ if ( lastEditTimestamp >= triggerTimestamp ) {
71+ return false ;
72+ }
73+ }
74+
75+ return true ;
76+ } ) ;
77+ }
78+
79+ /**
80+ * Filters reviews to only include those that existed in their final state before the trigger time.
81+ * Similar to filterCommentsToTriggerTime but for GitHubReview objects which use submittedAt instead of createdAt.
82+ */
83+ export function filterReviewsToTriggerTime <
84+ T extends { submittedAt : string ; updatedAt ?: string ; lastEditedAt ?: string } ,
85+ > ( reviews : T [ ] , triggerTime : string | undefined ) : T [ ] {
86+ if ( ! triggerTime ) return reviews ;
87+
88+ const triggerTimestamp = new Date ( triggerTime ) . getTime ( ) ;
89+
90+ return reviews . filter ( ( review ) => {
91+ // Review must have been submitted before trigger (not at or after)
92+ const submittedTimestamp = new Date ( review . submittedAt ) . getTime ( ) ;
93+ if ( submittedTimestamp >= triggerTimestamp ) {
94+ return false ;
95+ }
96+
97+ // If review has been edited, the most recent edit must have occurred before trigger
98+ const lastEditTime = review . lastEditedAt || review . updatedAt ;
99+ if ( lastEditTime ) {
100+ const lastEditTimestamp = new Date ( lastEditTime ) . getTime ( ) ;
101+ if ( lastEditTimestamp >= triggerTimestamp ) {
102+ return false ;
103+ }
104+ }
105+
106+ return true ;
107+ } ) ;
108+ }
109+
16110type FetchDataParams = {
17111 octokits : Octokits ;
18112 repository : string ;
19113 prNumber : string ;
20114 isPR : boolean ;
21115 triggerUsername ?: string ;
116+ triggerTime ?: string ;
22117} ;
23118
24119export type GitHubFileWithSHA = GitHubFile & {
@@ -41,6 +136,7 @@ export async function fetchGitHubData({
41136 prNumber,
42137 isPR,
43138 triggerUsername,
139+ triggerTime,
44140} : FetchDataParams ) : Promise < FetchDataResult > {
45141 const [ owner , repo ] = repository . split ( "/" ) ;
46142 if ( ! owner || ! repo ) {
@@ -68,7 +164,10 @@ export async function fetchGitHubData({
68164 const pullRequest = prResult . repository . pullRequest ;
69165 contextData = pullRequest ;
70166 changedFiles = pullRequest . files . nodes || [ ] ;
71- comments = pullRequest . comments ?. nodes || [ ] ;
167+ comments = filterCommentsToTriggerTime (
168+ pullRequest . comments ?. nodes || [ ] ,
169+ triggerTime ,
170+ ) ;
72171 reviewData = pullRequest . reviews || [ ] ;
73172
74173 console . log ( `Successfully fetched PR #${ prNumber } data` ) ;
@@ -88,7 +187,10 @@ export async function fetchGitHubData({
88187
89188 if ( issueResult . repository . issue ) {
90189 contextData = issueResult . repository . issue ;
91- comments = contextData ?. comments ?. nodes || [ ] ;
190+ comments = filterCommentsToTriggerTime (
191+ contextData ?. comments ?. nodes || [ ] ,
192+ triggerTime ,
193+ ) ;
92194
93195 console . log ( `Successfully fetched issue #${ prNumber } data` ) ;
94196 } else {
@@ -141,25 +243,35 @@ export async function fetchGitHubData({
141243 body : c . body ,
142244 } ) ) ;
143245
144- const reviewBodies : CommentWithImages [ ] =
145- reviewData ?. nodes
146- ?. filter ( ( r ) => r . body )
147- . map ( ( r ) => ( {
148- type : "review_body" as const ,
149- id : r . databaseId ,
150- pullNumber : prNumber ,
151- body : r . body ,
152- } ) ) ?? [ ] ;
153-
154- const reviewComments : CommentWithImages [ ] =
155- reviewData ?. nodes
156- ?. flatMap ( ( r ) => r . comments ?. nodes ?? [ ] )
157- . filter ( ( c ) => c . body && ! c . isMinimized )
158- . map ( ( c ) => ( {
159- type : "review_comment" as const ,
160- id : c . databaseId ,
161- body : c . body ,
162- } ) ) ?? [ ] ;
246+ // Filter review bodies to trigger time
247+ const filteredReviewBodies = reviewData ?. nodes
248+ ? filterReviewsToTriggerTime ( reviewData . nodes , triggerTime ) . filter (
249+ ( r ) => r . body ,
250+ )
251+ : [ ] ;
252+
253+ const reviewBodies : CommentWithImages [ ] = filteredReviewBodies . map ( ( r ) => ( {
254+ type : "review_body" as const ,
255+ id : r . databaseId ,
256+ pullNumber : prNumber ,
257+ body : r . body ,
258+ } ) ) ;
259+
260+ // Filter review comments to trigger time
261+ const allReviewComments =
262+ reviewData ?. nodes ?. flatMap ( ( r ) => r . comments ?. nodes ?? [ ] ) ?? [ ] ;
263+ const filteredReviewComments = filterCommentsToTriggerTime (
264+ allReviewComments ,
265+ triggerTime ,
266+ ) ;
267+
268+ const reviewComments : CommentWithImages [ ] = filteredReviewComments
269+ . filter ( ( c ) => c . body && ! c . isMinimized )
270+ . map ( ( c ) => ( {
271+ type : "review_comment" as const ,
272+ id : c . databaseId ,
273+ body : c . body ,
274+ } ) ) ;
163275
164276 // Add the main issue/PR body if it has content
165277 const mainBody : CommentWithImages [ ] = contextData . body
0 commit comments