@@ -9,6 +9,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
99const { sanitizeContent } = require ( "./sanitize_content.cjs" ) ;
1010const { buildWorkflowRunUrl } = require ( "./workflow_metadata_helpers.cjs" ) ;
1111const { isStagedMode } = require ( "./safe_output_helpers.cjs" ) ;
12+ const { logStagedPreviewInfo } = require ( "./staged_preview.cjs" ) ;
1213
1314/**
1415 * @typedef {'issue' | 'pull_request' } EntityType
@@ -50,10 +51,8 @@ function buildCommentBody(body, triggeringIssueNumber, triggeringPRNumber) {
5051 const workflowSourceURL = process . env . GH_AW_WORKFLOW_SOURCE_URL || "" ;
5152 const runUrl = buildWorkflowRunUrl ( context , context . repo ) ;
5253
53- // Sanitize the body content to prevent injection attacks
54- const sanitizedBody = sanitizeContent ( body ) ;
55-
56- return sanitizedBody . trim ( ) + getTrackerID ( "markdown" ) + generateFooterWithMessages ( workflowName , runUrl , workflowSource , workflowSourceURL , triggeringIssueNumber , triggeringPRNumber , undefined ) ;
54+ // Caller is responsible for sanitizing body before passing it here.
55+ return body . trim ( ) + getTrackerID ( "markdown" ) + generateFooterWithMessages ( workflowName , runUrl , workflowSource , workflowSourceURL , triggeringIssueNumber , triggeringPRNumber , undefined ) ;
5756}
5857
5958/**
@@ -204,6 +203,227 @@ function escapeMarkdownTitle(title) {
204203 return title . replace ( / [ [ \] ( ) ] / g, "\\$&" ) ;
205204}
206205
206+ /**
207+ * @typedef {Object } CloseEntityHandlerCallbacks
208+ * @property {(item: Object, config: Object) => ({success: true, entityNumber: number, owner: string, repo: string, entityRepo?: string} | {success: false, error: string}) } resolveTarget
209+ * Resolves the entity number and target repository from the message and handler config.
210+ * The factory passes both `item` and `config`; implementations may ignore `config` if not needed.
211+ * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, title: string, labels: Array<{name: string}>, html_url: string, state: string}> } getDetails
212+ * Fetches entity details from the GitHub API.
213+ * @property {(entity: Object, entityNumber: number, requiredLabels: string[]) => {valid: true} | {valid: false, warning?: string, error: string} } validateLabels
214+ * Validates entity labels against the required-labels filter.
215+ * @property {(sanitizedBody: string, item: Object) => string } buildCommentBody
216+ * Builds the final comment body from the already-sanitized body text.
217+ * The factory passes both `sanitizedBody` and `item`; implementations may ignore `item`
218+ * if they retrieve context values (e.g. triggering PR number) from the global `context` directly.
219+ * @property {(github: any, owner: string, repo: string, entityNumber: number, body: string) => Promise<{id: number, html_url: string}> } addComment
220+ * Posts a comment to the entity.
221+ * @property {(github: any, owner: string, repo: string, entityNumber: number, item: Object, config: Object) => Promise<{number: number, html_url: string, title: string}> } closeEntity
222+ * Closes the entity via the GitHub API.
223+ * The factory passes `item` and `config` for implementations that need per-item overrides
224+ * (e.g. `state_reason`); implementations that don't need them may ignore those parameters.
225+ * @property {(closedEntity: Object, commentResult: Object|null, wasAlreadyClosed: boolean, commentPosted: boolean) => Object } buildSuccessResult
226+ * Builds the success result object returned to the caller.
227+ * @property {boolean } [continueOnCommentError]
228+ * When true, a failed comment post is logged but does not abort the close operation.
229+ * When false/omitted, a comment failure propagates and causes the handler to return an error.
230+ */
231+
232+ /**
233+ * Create a message-level close-entity handler function.
234+ *
235+ * Centralises the common close-flow pipeline:
236+ * 1. Max-count gating
237+ * 2. Comment body resolution (item.body → config.comment fallback)
238+ * 3. Content sanitization
239+ * 4. Target repository / entity number resolution (via callbacks.resolveTarget)
240+ * 5. Entity details fetch + already-closed detection
241+ * 6. Label filter validation (via callbacks.validateLabels)
242+ * 7. Title-prefix filter validation
243+ * 8. Staged-mode preview short-circuit
244+ * 9. Comment posting (with optional continueOnCommentError)
245+ * 10. Entity close (skipped when already closed)
246+ * 11. Success result construction (via callbacks.buildSuccessResult)
247+ *
248+ * Entity-specific behaviour (API calls, label semantics, comment body
249+ * construction, result shape, cross-repo support) is supplied through the
250+ * callbacks argument so that each handler only retains the code that is
251+ * genuinely unique to it.
252+ *
253+ * @param {Object } config - Handler configuration object from main()
254+ * @param {EntityConfig } entityConfig - Entity display/type configuration
255+ * @param {CloseEntityHandlerCallbacks } callbacks - Entity-specific callbacks
256+ * @param {any } githubClient - Authenticated GitHub client
257+ * @returns {import('./types/handler-factory').MessageHandlerFunction } Message handler function
258+ */
259+ function createCloseEntityHandler ( config , entityConfig , callbacks , githubClient ) {
260+ const requiredLabels = config . required_labels || [ ] ;
261+ const requiredTitlePrefix = config . required_title_prefix || "" ;
262+ const maxCount = config . max || 10 ;
263+ const comment = config . comment || "" ;
264+ const isStaged = isStagedMode ( config ) ;
265+
266+ let processedCount = 0 ;
267+
268+ return async function handleCloseEntity ( message , resolvedTemporaryIds ) {
269+ // 1. Max-count gating
270+ if ( processedCount >= maxCount ) {
271+ core . warning ( `Skipping ${ entityConfig . itemType } : max count of ${ maxCount } reached` ) ;
272+ return { success : false , error : `Max count of ${ maxCount } reached` } ;
273+ }
274+ processedCount ++ ;
275+
276+ const item = message ;
277+
278+ // Log message structure for debugging (avoid logging body content)
279+ const logFields = { has_body : ! ! item . body , body_length : item . body ? item . body . length : 0 } ;
280+ if ( item [ entityConfig . numberField ] !== undefined ) {
281+ logFields [ entityConfig . numberField ] = item [ entityConfig . numberField ] ;
282+ }
283+ if ( item . repo !== undefined ) {
284+ logFields . has_repo = true ;
285+ }
286+ core . info ( `Processing ${ entityConfig . itemType } message: ${ JSON . stringify ( logFields ) } ` ) ;
287+
288+ // 2. Comment body resolution
289+ /** @type {string } */
290+ let commentToPost ;
291+ /** @type {string } */
292+ let commentSource = "unknown" ;
293+
294+ if ( typeof item . body === "string" && item . body . trim ( ) !== "" ) {
295+ commentToPost = item . body ;
296+ commentSource = "item.body" ;
297+ } else if ( typeof comment === "string" && comment . trim ( ) !== "" ) {
298+ commentToPost = comment ;
299+ commentSource = "config.comment" ;
300+ } else {
301+ core . warning ( "No comment body provided in message and no default comment configured" ) ;
302+ return { success : false , error : "No comment body provided" } ;
303+ }
304+
305+ core . info ( `Comment body determined: length=${ commentToPost . length } , source=${ commentSource } ` ) ;
306+
307+ // 3. Content sanitization
308+ commentToPost = sanitizeContent ( commentToPost ) ;
309+
310+ // 4. Target repository / entity number resolution
311+ const targetResult = callbacks . resolveTarget ( item , config ) ;
312+ if ( ! targetResult . success ) {
313+ core . warning ( `Skipping ${ entityConfig . itemType } : ${ targetResult . error } ` ) ;
314+ return { success : false , error : targetResult . error } ;
315+ }
316+ const { entityNumber, owner, repo : repoName , entityRepo } = targetResult ;
317+ if ( entityRepo ) {
318+ core . info ( `Target repository: ${ entityRepo } ` ) ;
319+ }
320+
321+ try {
322+ // 5. Entity details fetch
323+ core . info ( `Fetching ${ entityConfig . displayName } details for #${ entityNumber } in ${ owner } /${ repoName } ` ) ;
324+ const entity = await callbacks . getDetails ( githubClient , owner , repoName , entityNumber ) ;
325+ core . info ( `${ entityConfig . displayNameCapitalized } #${ entityNumber } fetched: state=${ entity . state } , title="${ entity . title } ", labels=[${ entity . labels . map ( l => l . name || l ) . join ( ", " ) } ]` ) ;
326+
327+ const wasAlreadyClosed = entity . state === "closed" ;
328+ if ( wasAlreadyClosed ) {
329+ core . info ( `${ entityConfig . displayNameCapitalized } #${ entityNumber } is already closed, but will still add comment` ) ;
330+ }
331+
332+ // 6. Label filter validation
333+ const labelResult = callbacks . validateLabels ( entity , entityNumber , requiredLabels ) ;
334+ if ( ! labelResult . valid ) {
335+ core . warning ( labelResult . warning || `Skipping ${ entityConfig . displayName } #${ entityNumber } : ${ labelResult . error } ` ) ;
336+ return { success : false , error : labelResult . error } ;
337+ }
338+ if ( requiredLabels . length > 0 ) {
339+ core . info ( `${ entityConfig . displayNameCapitalized } #${ entityNumber } has required labels: ${ requiredLabels . join ( ", " ) } ` ) ;
340+ }
341+
342+ // 7. Title-prefix filter validation
343+ if ( requiredTitlePrefix && ! checkTitlePrefixFilter ( entity . title , requiredTitlePrefix ) ) {
344+ core . warning ( `${ entityConfig . displayNameCapitalized } #${ entityNumber } title doesn't start with "${ requiredTitlePrefix } "` ) ;
345+ return { success : false , error : `Title doesn't start with "${ requiredTitlePrefix } "` } ;
346+ }
347+ if ( requiredTitlePrefix ) {
348+ core . info ( `${ entityConfig . displayNameCapitalized } #${ entityNumber } has required title prefix: "${ requiredTitlePrefix } "` ) ;
349+ }
350+
351+ // 8. Staged-mode preview short-circuit
352+ if ( isStaged ) {
353+ const repoStr = entityRepo || `${ owner } /${ repoName } ` ;
354+ logStagedPreviewInfo ( `Would close ${ entityConfig . displayName } #${ entityNumber } in ${ repoStr } ` ) ;
355+ return {
356+ success : true ,
357+ staged : true ,
358+ previewInfo : {
359+ number : entityNumber ,
360+ repo : repoStr ,
361+ alreadyClosed : wasAlreadyClosed ,
362+ hasComment : ! ! commentToPost ,
363+ } ,
364+ } ;
365+ }
366+
367+ // 9. Comment posting
368+ const commentBody = callbacks . buildCommentBody ( commentToPost , item ) ;
369+ core . info ( `Adding comment to ${ entityConfig . displayName } #${ entityNumber } : length=${ commentBody . length } ` ) ;
370+
371+ /** @type {{id: number, html_url: string}|null } */
372+ let commentResult = null ;
373+ let commentPosted = false ;
374+ try {
375+ commentResult = await callbacks . addComment ( githubClient , owner , repoName , entityNumber , commentBody ) ;
376+ commentPosted = true ;
377+ core . info ( `✓ Comment posted to ${ entityConfig . displayName } #${ entityNumber } : ${ commentResult . html_url } ` ) ;
378+ core . info ( `Comment details: id=${ commentResult . id } , body_length=${ commentBody . length } ` ) ;
379+ } catch ( commentError ) {
380+ const errorMsg = getErrorMessage ( commentError ) ;
381+ if ( callbacks . continueOnCommentError ) {
382+ core . error ( `Failed to add comment to ${ entityConfig . displayName } #${ entityNumber } : ${ errorMsg } ` ) ;
383+ core . error (
384+ `Error details: ${ JSON . stringify ( {
385+ entityNumber,
386+ hasBody : ! ! item . body ,
387+ bodyLength : item . body ? item . body . length : 0 ,
388+ errorMessage : errorMsg ,
389+ } ) } `
390+ ) ;
391+ // commentPosted stays false; close operation continues
392+ } else {
393+ throw commentError ;
394+ }
395+ }
396+
397+ // 10. Entity close (skipped when already closed)
398+ let closedEntity ;
399+ if ( wasAlreadyClosed ) {
400+ core . info ( `${ entityConfig . displayNameCapitalized } #${ entityNumber } was already closed, comment ${ commentPosted ? "added successfully" : "posting attempted" } ` ) ;
401+ closedEntity = entity ;
402+ } else {
403+ closedEntity = await callbacks . closeEntity ( githubClient , owner , repoName , entityNumber , item , config ) ;
404+ core . info ( `✓ ${ entityConfig . displayNameCapitalized } #${ entityNumber } closed successfully: ${ closedEntity . html_url } ` ) ;
405+ }
406+
407+ core . info ( `${ entityConfig . itemType } completed successfully for ${ entityConfig . displayName } #${ entityNumber } ` ) ;
408+
409+ // 11. Success result construction
410+ return callbacks . buildSuccessResult ( closedEntity , commentResult , wasAlreadyClosed , commentPosted ) ;
411+ } catch ( error ) {
412+ const errorMessage = getErrorMessage ( error ) ;
413+ core . error ( `Failed to close ${ entityConfig . displayName } #${ entityNumber } : ${ errorMessage } ` ) ;
414+ core . error (
415+ `Error details: ${ JSON . stringify ( {
416+ entityNumber,
417+ hasBody : ! ! item . body ,
418+ bodyLength : item . body ? item . body . length : 0 ,
419+ errorMessage,
420+ } ) } `
421+ ) ;
422+ return { success : false , error : errorMessage } ;
423+ }
424+ } ;
425+ }
426+
207427/**
208428 * Process close entity items from agent output
209429 * @param {EntityConfig } config - Entity configuration
@@ -292,8 +512,9 @@ async function processCloseEntityItems(config, callbacks, handlerConfig = {}) {
292512 core . info ( `${ config . displayNameCapitalized } #${ entityNumber } is already closed, but will still add comment` ) ;
293513 }
294514
295- // Build comment body
296- const commentBody = buildCommentBody ( item . body , triggeringIssueNumber , triggeringPRNumber ) ;
515+ // Build comment body (sanitize first, then append tracker/footer)
516+ const sanitizedItemBody = sanitizeContent ( item . body ) ;
517+ const commentBody = buildCommentBody ( sanitizedItemBody , triggeringIssueNumber , triggeringPRNumber ) ;
297518
298519 // Add comment before closing (or to already-closed entity)
299520 const comment = await callbacks . addComment ( github , context . repo . owner , context . repo . repo , entityNumber , commentBody ) ;
@@ -389,6 +610,7 @@ module.exports = {
389610 resolveEntityNumber,
390611 buildCommentBody,
391612 escapeMarkdownTitle,
613+ createCloseEntityHandler,
392614 ISSUE_CONFIG ,
393615 PULL_REQUEST_CONFIG ,
394616} ;
0 commit comments