Skip to content

Commit 314d821

Browse files
authored
refactor: centralize close-flow logic into shared createCloseEntityHandler factory (#25628)
1 parent 7b2108a commit 314d821

4 files changed

Lines changed: 356 additions & 460 deletions

File tree

actions/setup/js/close_entity_helpers.cjs

Lines changed: 228 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
99
const { sanitizeContent } = require("./sanitize_content.cjs");
1010
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
1111
const { 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

Comments
 (0)