diff --git a/actions/setup/js/safe_output_unified_handler_manager.cjs b/actions/setup/js/safe_output_unified_handler_manager.cjs deleted file mode 100644 index ad42acbb01..0000000000 --- a/actions/setup/js/safe_output_unified_handler_manager.cjs +++ /dev/null @@ -1,1185 +0,0 @@ -// @ts-check -/// - -/** - * Unified Safe Output Handler Manager - * - * This module manages the dispatch of safe output messages to dedicated handlers. - * It processes both regular and project-related safe outputs in a single step, - * using the appropriate GitHub client based on the handler type. - * - * Regular handlers use the `github` object from github-script (authenticated with GH_AW_GITHUB_TOKEN) - * Project handlers use a separate Octokit instance (authenticated with GH_AW_PROJECT_GITHUB_TOKEN) - * - * The @actions/github package is installed at runtime via setup.sh to enable Octokit instantiation. - */ - -const { loadAgentOutput } = require("./load_agent_output.cjs"); -const { getErrorMessage } = require("./error_helpers.cjs"); -const { ERR_CONFIG, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs"); -const { hasUnresolvedTemporaryIds, replaceTemporaryIdReferences, normalizeTemporaryId, loadTemporaryIdMap, isTemporaryId } = require("./temporary_id.cjs"); -const { generateMissingInfoSections } = require("./missing_info_formatter.cjs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); -const { setCollectedMissings } = require("./missing_messages_helper.cjs"); -const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs"); -const { getIssuesToAssignCopilot } = require("./create_issue.cjs"); -const { sortSafeOutputMessages } = require("./safe_output_topological_sort.cjs"); -const { loadCustomSafeOutputJobTypes } = require("./safe_output_helpers.cjs"); -const { createReviewBuffer } = require("./pr_review_buffer.cjs"); -const { createManifestLogger, ensureManifestExists, extractCreatedItemFromResult } = require("./safe_output_manifest.cjs"); -const { emitSafeOutputActionOutputs } = require("./safe_outputs_action_outputs.cjs"); - -/** - * Handler map configuration for regular handlers - * Maps safe output types to their handler module file paths - * These handlers use the `github` object from github-script - */ -const HANDLER_MAP = { - create_issue: "./create_issue.cjs", - add_comment: "./add_comment.cjs", - create_discussion: "./create_discussion.cjs", - close_issue: "./close_issue.cjs", - close_discussion: "./close_discussion.cjs", - add_labels: "./add_labels.cjs", - remove_labels: "./remove_labels.cjs", - update_issue: "./update_issue.cjs", - update_discussion: "./update_discussion.cjs", - link_sub_issue: "./link_sub_issue.cjs", - update_release: "./update_release.cjs", - create_pull_request_review_comment: "./create_pr_review_comment.cjs", - submit_pull_request_review: "./submit_pr_review.cjs", - reply_to_pull_request_review_comment: "./reply_to_pr_review_comment.cjs", - resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs", - create_pull_request: "./create_pull_request.cjs", - push_to_pull_request_branch: "./push_to_pull_request_branch.cjs", - update_pull_request: "./update_pull_request.cjs", - close_pull_request: "./close_pull_request.cjs", - mark_pull_request_as_ready_for_review: "./mark_pull_request_as_ready_for_review.cjs", - hide_comment: "./hide_comment.cjs", - add_reviewer: "./add_reviewer.cjs", - assign_milestone: "./assign_milestone.cjs", - assign_to_user: "./assign_to_user.cjs", - unassign_from_user: "./unassign_from_user.cjs", - create_code_scanning_alert: "./create_code_scanning_alert.cjs", - autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs", - dispatch_workflow: "./dispatch_workflow.cjs", - create_missing_tool_issue: "./create_missing_tool_issue.cjs", - missing_tool: "./missing_tool.cjs", - create_missing_data_issue: "./create_missing_data_issue.cjs", - missing_data: "./missing_data.cjs", - noop: "./noop_handler.cjs", -}; - -/** - * Handler map configuration for project handlers - * Maps project-related safe output types to their handler module file paths - * These handlers require GH_AW_PROJECT_GITHUB_TOKEN and use an Octokit instance - */ -const PROJECT_HANDLER_MAP = { - create_project: "./create_project.cjs", - create_project_status_update: "./create_project_status_update.cjs", - update_project: "./update_project.cjs", -}; - -/** - * Message types handled by standalone steps (not through the handler manager) - * These types should not trigger warnings when skipped by the handler manager - * - * Other standalone types: assign_to_agent, create_agent_session, upload_asset, noop - * - Have dedicated processing steps with specialized logic - */ -const STANDALONE_STEP_TYPES = new Set(["assign_to_agent", "create_agent_session", "upload_asset", "noop"]); - -/** - * Project-related message types that are handled by project handlers - * Used to provide more specific handling - */ -const PROJECT_RELATED_TYPES = new Set(Object.keys(PROJECT_HANDLER_MAP)); - -/** - * Load configuration for safe outputs - * Reads configuration from both GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG and GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG - * Automatically splits project handlers from regular config if they're in the wrong place - * @returns {{regular: Object, project: Object}} Safe outputs configuration for regular and project handlers - */ -function loadConfig() { - const regular = {}; - const project = {}; - - // Load regular handler config - if (process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) { - try { - const config = JSON.parse(process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG); - core.info(`Loaded config from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ${JSON.stringify(config)}`); - - // Normalize config keys: convert hyphens to underscores - const normalizedEntries = Object.entries(config).map(([k, v]) => [k.replace(/-/g, "_"), v]); - - // Automatically split project handlers from regular handlers - // Project handlers (update_project, create_project, create_project_status_update) require - // a separate Octokit client authenticated with GH_AW_PROJECT_GITHUB_TOKEN because they need - // Projects permissions that differ from regular handler permissions. This auto-split ensures - // backward compatibility with the Go compiler which puts all handlers in a unified config. - for (const [key, value] of normalizedEntries) { - if (PROJECT_RELATED_TYPES.has(key)) { - project[key] = value; - core.info(`Auto-moved ${key} from unified config to project config (requires project token)`); - } else { - regular[key] = value; - } - } - } catch (error) { - throw new Error(`${ERR_PARSE}: Failed to parse GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ${getErrorMessage(error)}`); - } - } - - // Load project handler config (if explicitly provided, merge with auto-split handlers) - if (process.env.GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG) { - try { - const config = JSON.parse(process.env.GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG); - core.info(`Loaded project handler config: ${JSON.stringify(config)}`); - // Normalize config keys: convert hyphens to underscores - // Explicitly provided project config takes precedence over auto-split config - Object.assign(project, Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace(/-/g, "_"), v]))); - } catch (error) { - throw new Error(`${ERR_PARSE}: Failed to parse GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: ${getErrorMessage(error)}`); - } - } - - // At least one config must be present - if (Object.keys(regular).length === 0 && Object.keys(project).length === 0) { - throw new Error(`${ERR_CONFIG}: At least one of GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG or GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG environment variables is required`); - } - - const regularCount = Object.keys(regular).length; - const projectCount = Object.keys(project).length; - core.info(`Configuration loaded: ${regularCount} regular handler${regularCount === 1 ? "" : "s"}, ${projectCount} project handler${projectCount === 1 ? "" : "s"}`); - if (projectCount > 0) { - core.info(`Project handlers: ${Object.keys(project).join(", ")}`); - } - - return { regular, project }; -} - -/** - * Setup a separate GitHub client for project handlers using Octokit - * Creates an Octokit instance authenticated with GH_AW_PROJECT_GITHUB_TOKEN - * This is necessary because project handlers need different permissions than regular handlers - * @returns {Promise} Octokit instance for project handlers - */ -async function setupProjectGitHubClient() { - const projectToken = process.env.GH_AW_PROJECT_GITHUB_TOKEN; - if (!projectToken) { - throw new Error(`${ERR_CONFIG}: GH_AW_PROJECT_GITHUB_TOKEN environment variable is required for project-related safe outputs. Configure a GitHub token with Projects permissions in your workflow secrets.`); - } - - core.info("Setting up separate Octokit client for project handlers with GH_AW_PROJECT_GITHUB_TOKEN"); - - // Lazy-load @actions/github only when needed (may not be installed for workflows without project safe outputs) - // Use dynamic import for ESM module compatibility - const { getOctokit } = await import("@actions/github"); - const octokit = getOctokit(projectToken); - - return octokit; -} - -/** @type {Set} Handler types that participate in the PR review buffer */ -const PR_REVIEW_HANDLER_TYPES = new Set(["create_pull_request_review_comment", "submit_pull_request_review"]); - -/** - * Load and initialize handlers for enabled safe output types - * Calls each handler's factory function (main) to get message processors - * Regular handlers use the global github object, project handlers use a separate Octokit instance - * @param {{regular: Object, project: Object}} configs - Safe outputs configuration for regular and project handlers - * @param {Object} projectOctokit - Octokit instance for project handlers (optional, required if project handlers are configured) - * @param {Object} prReviewBuffer - Shared PR review buffer instance - * @returns {Promise>} Map of type to message handler function - */ -async function loadHandlers(configs, projectOctokit = null, prReviewBuffer = null) { - const messageHandlers = new Map(); - - core.info("Loading and initializing safe output handlers based on configuration..."); - - // Load regular handlers (using the github object from github-script context) - for (const [type, handlerPath] of Object.entries(HANDLER_MAP)) { - if (configs.regular[type]) { - try { - const handlerModule = require(handlerPath); - if (handlerModule && typeof handlerModule.main === "function") { - // Call the factory function with config to get the message handler - const handlerConfig = { ...(configs.regular[type] || {}) }; - - // Inject shared PR review buffer into handlers that need it - if (PR_REVIEW_HANDLER_TYPES.has(type) && prReviewBuffer) { - handlerConfig._prReviewBuffer = prReviewBuffer; - } - - const messageHandler = await handlerModule.main(handlerConfig); - - if (typeof messageHandler !== "function") { - const error = new Error(`Handler ${type} main() did not return a function - expected a message handler function but got ${typeof messageHandler}`); - core.error(`✗ Fatal error loading handler ${type}: ${error.message}`); - throw error; - } - - messageHandlers.set(type, messageHandler); - core.info(`✓ Loaded and initialized regular handler for: ${type}`); - } else { - core.warning(`Handler module ${type} does not export a main function`); - } - } catch (error) { - const errorMessage = getErrorMessage(error); - if (errorMessage.includes("did not return a function")) { - throw error; - } - core.warning(`Failed to load regular handler for ${type}: ${errorMessage}`); - } - } - } - - // Load project handlers (using a separate Octokit instance with project token) - // Project handlers require different authentication (GH_AW_PROJECT_GITHUB_TOKEN) - for (const [type, handlerPath] of Object.entries(PROJECT_HANDLER_MAP)) { - if (configs.project[type]) { - try { - // Ensure we have an Octokit instance for project handlers - if (!projectOctokit) { - throw new Error(`${ERR_CONFIG}: Octokit instance is required for project handler ${type}. This is a configuration error - projectOctokit should be provided when project handlers are configured.`); - } - - const handlerModule = require(handlerPath); - if (handlerModule && typeof handlerModule.main === "function") { - // Call the factory function with config AND the project Octokit client - const handlerConfig = configs.project[type] || {}; - const messageHandler = await handlerModule.main(handlerConfig, projectOctokit); - - if (typeof messageHandler !== "function") { - const error = new Error(`Handler ${type} main() did not return a function - expected a message handler function but got ${typeof messageHandler}`); - core.error(`✗ Fatal error loading handler ${type}: ${error.message}`); - throw error; - } - - messageHandlers.set(type, messageHandler); - core.info(`✓ Loaded and initialized project handler for: ${type}`); - } else { - core.warning(`Handler module ${type} does not export a main function`); - } - } catch (error) { - const errorMessage = getErrorMessage(error); - if (errorMessage.includes("did not return a function")) { - throw error; - } - core.warning(`Failed to load project handler for ${type}: ${errorMessage}`); - } - } - } - - core.info(`Loaded ${messageHandlers.size} handler(s) total`); - return messageHandlers; -} - -/** - * Collect missing_tool, missing_data, and noop messages from the messages array - * @param {Array} messages - Array of safe output messages - * @returns {{missingTools: Array, missingData: Array, noopMessages: Array}} Object with collected missing items and noop messages - */ -function collectMissingMessages(messages) { - const missingTools = []; - const missingData = []; - const noopMessages = []; - - for (const message of messages) { - if (message.type === "missing_tool") { - // Extract relevant fields from missing_tool message - if (message.tool && message.reason) { - missingTools.push({ - tool: message.tool, - reason: message.reason, - alternatives: message.alternatives || null, - }); - } - } else if (message.type === "missing_data") { - // Extract relevant fields from missing_data message - if (message.data_type && message.reason) { - missingData.push({ - data_type: message.data_type, - reason: message.reason, - context: message.context || null, - alternatives: message.alternatives || null, - }); - } - } else if (message.type === "noop") { - // Extract relevant fields from noop message - if (message.message) { - noopMessages.push({ - message: message.message, - }); - } - } - } - - core.info(`Collected ${missingTools.length} missing tool(s), ${missingData.length} missing data item(s), and ${noopMessages.length} noop message(s)`); - return { missingTools, missingData, noopMessages }; -} - -/** - * Process all messages from agent output in topologically sorted order - * Dispatches each message to the appropriate handler while maintaining shared state (unified temporary ID map) - * Tracks outputs created with unresolved temporary IDs and generates synthetic updates after resolution - * - * Messages are sorted topologically based on temporary ID dependencies before processing. - * This ensures items without temporary IDs are created first, enabling single-pass resolution - * of temporary IDs in acyclic dependency graphs. - * - * The unified temporary ID map stores both issue/PR references and project URLs: - * - Issue/PR: temporary_id -> {repo: string, number: number} - * - Project: temporary_id -> {projectUrl: string} - * - * @param {Map} messageHandlers - Map of message handler functions - * @param {Array} messages - Array of safe output messages - * @param {Object} projectOctokit - Separate Octokit instance for project handlers (optional) - * @param {((item: {type: string, url?: string, number?: number, repo?: string, temporaryId?: string}) => void)|null} onItemCreated - Optional callback invoked after each successful create operation (for manifest logging) - * @returns {Promise<{success: boolean, results: Array, temporaryIdMap: Object, outputsWithUnresolvedIds: Array, missings: Object}>} - */ -async function processMessages(messageHandlers, messages, projectOctokit = null, onItemCreated = null) { - const results = []; - - // Collect missing_tool and missing_data messages first - const missings = collectMissingMessages(messages); - - // Load custom safe output job types that are processed by dedicated custom jobs - const customSafeOutputJobTypes = loadCustomSafeOutputJobTypes(); - - // Sort messages topologically based on temporary ID dependencies - // This ensures messages that create entities are processed before messages that reference them - const sortedMessages = sortSafeOutputMessages(messages); - - // Initialize unified temporary ID map - // This will be populated by handlers as they create entities with temporary IDs - // Stores both issue/PR references ({repo, number}), project URLs ({projectUrl}), and draft items ({draftItemId}) - /** @type {Map} */ - const temporaryIdMap = new Map(); - - // Load existing temporary ID map from environment (if provided from previous step) - const existingTempIdMap = loadTemporaryIdMap(); - if (existingTempIdMap.size > 0) { - core.info(`Loaded existing temporary ID map with ${existingTempIdMap.size} entry(ies)`); - // Merge existing map into our working map - for (const [key, value] of existingTempIdMap.entries()) { - temporaryIdMap.set(key, value); - } - } - - // Track outputs that were created with unresolved temporary IDs - // Format: {type, message, result, originalTempIdMapSize} - /** @type {Array<{type: string, message: any, result: any, originalTempIdMapSize: number}>} */ - const outputsWithUnresolvedIds = []; - - // Track messages that were deferred due to unresolved temporary IDs - // These will be retried after the first pass when more temp IDs may be resolved - /** @type {Array<{type: string, message: any, messageIndex: number, handler: Function}>} */ - const deferredMessages = []; - - core.info(`Processing ${sortedMessages.length} message(s) in topologically sorted order...`); - - // Process messages in topologically sorted order - for (let i = 0; i < sortedMessages.length; i++) { - const message = sortedMessages[i]; - const messageType = message.type; - - if (!messageType) { - core.warning(`Skipping message ${i + 1} without type`); - continue; - } - - const messageHandler = messageHandlers.get(messageType); - - if (!messageHandler) { - // Check if this message type is handled by a standalone step - if (STANDALONE_STEP_TYPES.has(messageType)) { - // Silently skip - this is handled by a dedicated step - core.debug(`Message ${i + 1} (${messageType}) will be handled by standalone step`); - results.push({ - type: messageType, - messageIndex: i, - success: false, - skipped: true, - reason: "Handled by standalone step", - }); - continue; - } - - // Check if this message type is a custom safe output job - if (customSafeOutputJobTypes.has(messageType)) { - // Silently skip - this is handled by a custom safe output job - core.debug(`Message ${i + 1} (${messageType}) will be handled by custom safe output job`); - results.push({ - type: messageType, - messageIndex: i, - success: false, - skipped: true, - reason: "Handled by custom safe output job", - }); - continue; - } - - // Unknown message type - warn the user - core.warning( - `⚠️ No handler loaded for message type '${messageType}' (message ${i + 1}/${messages.length}). The message will be skipped. This may happen if the safe output type is not configured in the workflow's safe-outputs section.` - ); - results.push({ - type: messageType, - messageIndex: i, - success: false, - error: `No handler loaded for type '${messageType}'`, - }); - continue; - } - - try { - core.info(`Processing message ${i + 1}/${messages.length}: ${messageType}`); - - normalizeAndValidateTemporaryId(message, messageType, i); - - // Record the temp ID map size before processing to detect new IDs - const tempIdMapSizeBefore = temporaryIdMap.size; - - // Determine if this is a project-related handler - const isProjectHandler = PROJECT_RELATED_TYPES.has(messageType); - - let result; - // Convert Map to plain object for handler - both handler types use the same unified map - const resolvedTemporaryIds = Object.fromEntries(temporaryIdMap); - - if (isProjectHandler) { - // Project handlers receive: (message, temporaryIdMap, resolvedTemporaryIds) - // Note: Project handlers already have the project Octokit bound during initialization - result = await messageHandler(message, temporaryIdMap, resolvedTemporaryIds); - } else { - // Regular handlers receive: (message, resolvedTemporaryIds) - result = await messageHandler(message, resolvedTemporaryIds); - } - - // Check if the handler explicitly returned a failure - if (result && result.success === false && !result.deferred) { - const errorMsg = result.error || "Handler returned success: false"; - core.error(`✗ Message ${i + 1} (${messageType}) failed: ${errorMsg}`); - results.push({ - type: messageType, - messageIndex: i, - success: false, - error: errorMsg, - }); - continue; - } - - // Check if the operation was deferred due to unresolved temporary IDs - if (result && result.deferred === true) { - core.info(`⏸ Message ${i + 1} (${messageType}) deferred - will retry after first pass`); - deferredMessages.push({ - type: messageType, - message: message, - messageIndex: i, - handler: messageHandler, - }); - results.push({ - type: messageType, - messageIndex: i, - success: false, - deferred: true, - result, - }); - continue; - } - - // If handler returned a temp ID mapping for issue/PR, add it to our unified map - if (result && result.temporaryId && result.repo && result.number) { - const normalizedTempId = normalizeTemporaryId(result.temporaryId); - temporaryIdMap.set(normalizedTempId, { - repo: result.repo, - number: result.number, - }); - core.info(`Registered temporary ID: ${result.temporaryId} -> ${result.repo}#${result.number}`); - } - - // If this was a create_project, store the project URL in the unified map - if (messageType === "create_project" && result && result.projectUrl && message.temporary_id) { - const normalizedTempId = normalizeTemporaryId(message.temporary_id); - temporaryIdMap.set(normalizedTempId, { - projectUrl: result.projectUrl, - }); - core.info(`✓ Stored project mapping: ${message.temporary_id} -> ${result.projectUrl}`); - } - - // If this was an update_project that created a draft issue, store the draft item mapping - if (messageType === "update_project" && result && result.temporaryId && result.draftItemId) { - const normalizedTempId = normalizeTemporaryId(result.temporaryId); - temporaryIdMap.set(normalizedTempId, { - draftItemId: result.draftItemId, - }); - core.info(`✓ Stored draft issue mapping: ${result.temporaryId} -> draft item ${result.draftItemId}`); - } - - // Check if this output was created with unresolved temporary IDs - // For create_issue, create_discussion, add_comment - check if body has unresolved IDs - - // Handle add_comment which returns an array of comments - if (messageType === "add_comment" && Array.isArray(result)) { - const contentToCheck = getContentToCheck(messageType, message); - if (contentToCheck && hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap)) { - // Track each comment that was created with unresolved temp IDs - for (const comment of result) { - if (comment._tracking) { - core.info(`Comment ${comment._tracking.commentId} on ${comment._tracking.repo}#${comment._tracking.itemNumber} was created with unresolved temporary IDs - tracking for update`); - outputsWithUnresolvedIds.push({ - type: messageType, - message: message, - result: { - commentId: comment._tracking.commentId, - itemNumber: comment._tracking.itemNumber, - repo: comment._tracking.repo, - isDiscussion: comment._tracking.isDiscussion, - }, - originalTempIdMapSize: tempIdMapSizeBefore, - }); - } - } - } - } else if (result && result.number && result.repo) { - // Handle create_issue, create_discussion - const contentToCheck = getContentToCheck(messageType, message); - if (contentToCheck && hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap)) { - core.info(`Output ${result.repo}#${result.number} was created with unresolved temporary IDs - tracking for update`); - outputsWithUnresolvedIds.push({ - type: messageType, - message: message, - result: result, - originalTempIdMapSize: tempIdMapSizeBefore, - }); - } - } - - results.push({ - type: messageType, - messageIndex: i, - success: true, - result, - }); - - // Log to manifest if this was a create operation - if (onItemCreated) { - if (Array.isArray(result)) { - // Handle array results (e.g., batch add_comment) - for (const item of result) { - const createdItem = extractCreatedItemFromResult(messageType, item); - if (createdItem) onItemCreated(createdItem); - } - } else { - const createdItem = extractCreatedItemFromResult(messageType, result); - if (createdItem) onItemCreated(createdItem); - } - } - - core.info(`✓ Message ${i + 1} (${messageType}) completed successfully`); - } catch (error) { - core.error(`✗ Message ${i + 1} (${messageType}) failed: ${getErrorMessage(error)}`); - results.push({ - type: messageType, - messageIndex: i, - success: false, - error: getErrorMessage(error), - }); - } - } - - // Retry deferred messages now that more temporary IDs may have been resolved - // This retry loop mirrors the main processing loop but operates on messages that were - // deferred during the first pass (e.g., link_sub_issue waiting for parent/sub creation). - // IMPORTANT: Like the main loop, this must register temporary IDs and track outputs - // with unresolved IDs to enable full synthetic update resolution. - if (deferredMessages.length > 0) { - core.info(`\n=== Retrying Deferred Messages ===`); - core.info(`Found ${deferredMessages.length} deferred message(s) to retry`); - - for (const deferred of deferredMessages) { - try { - core.info(`Retrying message ${deferred.messageIndex + 1}/${messages.length}: ${deferred.type}`); - - normalizeAndValidateTemporaryId(deferred.message, deferred.type, deferred.messageIndex); - - // Convert Map to plain object for handler - const resolvedTemporaryIds = Object.fromEntries(temporaryIdMap); - - // Record the temp ID map size before processing to detect new IDs - const tempIdMapSizeBefore = temporaryIdMap.size; - - // Call the handler again with updated temp ID map - const result = await deferred.handler(deferred.message, resolvedTemporaryIds); - - // Check if the handler explicitly returned a failure - if (result && result.success === false && !result.deferred) { - const errorMsg = result.error || "Handler returned success: false"; - core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${errorMsg}`); - // Update the result to error - const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); - if (resultIndex >= 0) { - results[resultIndex].success = false; - results[resultIndex].error = errorMsg; - } - continue; - } - - // Check if still deferred - if (result && result.deferred === true) { - core.warning(`⏸ Message ${deferred.messageIndex + 1} (${deferred.type}) still deferred - some temporary IDs remain unresolved`); - // Update the existing result entry - const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); - if (resultIndex >= 0) { - results[resultIndex].result = result; - } - } else { - core.info(`✓ Message ${deferred.messageIndex + 1} (${deferred.type}) completed on retry`); - - // If handler returned a temp ID mapping, add it to our map - // This ensures that sub-issues created during deferred retry have their temporary IDs - // registered so parent issues can reference them in synthetic updates - if (result && result.temporaryId && result.repo && result.number) { - const normalizedTempId = normalizeTemporaryId(result.temporaryId); - temporaryIdMap.set(normalizedTempId, { - repo: result.repo, - number: result.number, - }); - core.info(`Registered temporary ID: ${result.temporaryId} -> ${result.repo}#${result.number}`); - } - - // Check if this output was created with unresolved temporary IDs - // For create_issue, create_discussion - check if body has unresolved IDs - // This enables synthetic updates to resolve references after all items are created - if (result && result.number && result.repo) { - const contentToCheck = getContentToCheck(deferred.type, deferred.message); - if (contentToCheck && hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap)) { - core.info(`Output ${result.repo}#${result.number} was created with unresolved temporary IDs - tracking for update`); - outputsWithUnresolvedIds.push({ - type: deferred.type, - message: deferred.message, - result: result, - originalTempIdMapSize: tempIdMapSizeBefore, - }); - } - } - - // Update the result to success - const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); - if (resultIndex >= 0) { - results[resultIndex].success = true; - results[resultIndex].deferred = false; - results[resultIndex].result = result; - } - - // Log to manifest if this was a create operation - if (onItemCreated) { - const createdItem = extractCreatedItemFromResult(deferred.type, result); - if (createdItem) onItemCreated(createdItem); - } - } - } catch (error) { - core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${getErrorMessage(error)}`); - // Update the result to error - const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); - if (resultIndex >= 0) { - results[resultIndex].error = getErrorMessage(error); - } - } - } - } - - // Return outputs with unresolved IDs for synthetic update processing - // Convert unified temporaryIdMap to plain object for serialization - const temporaryIdMapObj = Object.fromEntries(temporaryIdMap); - - return { - success: true, - results, - temporaryIdMap: temporaryIdMapObj, - outputsWithUnresolvedIds, - missings, - }; -} - -/** - * Get the content field to check for unresolved temporary IDs based on message type - * @param {string} messageType - Type of the message - * @param {any} message - The message object - * @returns {string|null} Content to check for temporary IDs - */ -function getContentToCheck(messageType, message) { - switch (messageType) { - case "create_issue": - return message.body || ""; - case "create_discussion": - return message.body || ""; - case "add_comment": - return message.body || ""; - default: - return null; - } -} - -/** - * Validate and normalize `temporary_id` on an agent-provided safe output message. - * Agents are not trusted to follow schemas; this enforces the strict format at runtime. - * - * - Accepts optional leading '#', normalizes to bare 'aw_...' string - * - Rejects any non-strict IDs (e.g. 'aw_bundle_npm001') - * - * @param {any} message - Safe output message - * @param {string} messageType - Message type - * @param {number} messageIndex - 0-based index for error context - */ -function normalizeAndValidateTemporaryId(message, messageType, messageIndex) { - if (!message || typeof message !== "object") { - return; - } - - // Support accidental camelCase from agents; normalize to snake_case. - if (message.temporary_id === undefined && message.temporaryId !== undefined) { - message.temporary_id = message.temporaryId; - } - - if (message.temporary_id === undefined || message.temporary_id === null) { - return; - } - - if (typeof message.temporary_id !== "string") { - throw new Error(`${ERR_VALIDATION}: Message ${messageIndex + 1} (${messageType}): temporary_id must be a string (got ${typeof message.temporary_id})`); - } - - const raw = message.temporary_id; - const trimmed = raw.trim(); - const withoutHash = trimmed.startsWith("#") ? trimmed.substring(1).trim() : trimmed; - - if (!isTemporaryId(withoutHash)) { - throw new Error(`${ERR_VALIDATION}: Message ${messageIndex + 1} (${messageType}): invalid temporary_id '${raw}'. Temporary IDs must be 'aw_' followed by 3 to 12 alphanumeric characters (A-Za-z0-9), e.g. 'aw_abc' or 'aw_Test123'`); - } - - // Normalize to the strict bare ID to keep lookups consistent. - message.temporary_id = withoutHash.toLowerCase(); -} - -/** - * Update the body of an issue with resolved temporary IDs - * @param {any} github - GitHub API client - * @param {any} context - GitHub Actions context - * @param {string} repo - Repository in "owner/repo" format - * @param {number} issueNumber - Issue number to update - * @param {string} updatedBody - Updated body content with resolved temp IDs - * @returns {Promise} - */ -async function updateIssueBody(github, context, repo, issueNumber, updatedBody) { - const [owner, repoName] = repo.split("/"); - - core.info(`Updating issue ${repo}#${issueNumber} body with resolved temporary IDs`); - - await github.rest.issues.update({ - owner, - repo: repoName, - issue_number: issueNumber, - body: sanitizeContent(updatedBody), - }); - - core.info(`✓ Updated issue ${repo}#${issueNumber}`); -} - -/** - * Update the body of a discussion with resolved temporary IDs - * @param {any} github - GitHub API client - * @param {any} context - GitHub Actions context - * @param {string} repo - Repository in "owner/repo" format - * @param {number} discussionNumber - Discussion number to update - * @param {string} updatedBody - Updated body content with resolved temp IDs - * @returns {Promise} - */ -async function updateDiscussionBody(github, context, repo, discussionNumber, updatedBody) { - const [owner, repoName] = repo.split("/"); - - core.info(`Updating discussion ${repo}#${discussionNumber} body with resolved temporary IDs`); - - // Get the discussion node ID first - const query = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $number) { - id - } - } - } - `; - - const result = await github.graphql(query, { - owner, - repo: repoName, - number: discussionNumber, - }); - - const discussionId = result.repository.discussion.id; - - // Update the discussion body using GraphQL mutation - const mutation = ` - mutation($discussionId: ID!, $body: String!) { - updateDiscussion(input: {discussionId: $discussionId, body: $body}) { - discussion { - id - number - } - } - } - `; - - await github.graphql(mutation, { - discussionId, - body: sanitizeContent(updatedBody), - }); - - core.info(`✓ Updated discussion ${repo}#${discussionNumber}`); -} - -/** - * Update the body of a comment with resolved temporary IDs - * @param {any} github - GitHub API client - * @param {any} context - GitHub Actions context - * @param {string} repo - Repository in "owner/repo" format - * @param {number} commentId - Comment ID to update - * @param {string} updatedBody - Updated body content with resolved temp IDs - * @param {boolean} isDiscussion - Whether this is a discussion comment - * @returns {Promise} - */ -async function updateCommentBody(github, context, repo, commentId, updatedBody, isDiscussion = false) { - const [owner, repoName] = repo.split("/"); - - core.info(`Updating comment ${commentId} body with resolved temporary IDs`); - - const sanitizedBody = sanitizeContent(updatedBody); - - if (isDiscussion) { - // For discussion comments, we need to use GraphQL - // Get the comment node ID first - const mutation = ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: {commentId: $commentId, body: $body}) { - comment { - id - } - } - } - `; - - await github.graphql(mutation, { - commentId, - body: sanitizedBody, - }); - } else { - // For issue/PR comments, use REST API - await github.rest.issues.updateComment({ - owner, - repo: repoName, - comment_id: commentId, - body: sanitizedBody, - }); - } - - core.info(`✓ Updated comment ${commentId}`); -} - -/** - * Process synthetic updates by directly updating the body of outputs with resolved temporary IDs - * Does not use safe output handlers - directly calls GitHub API to update content - * @param {any} github - GitHub API client - * @param {any} context - GitHub Actions context - * @param {Array<{type: string, message: any, result: any, originalTempIdMapSize: number}>} trackedOutputs - Outputs that need updating - * @param {Map} temporaryIdMap - Current temporary ID map - * @returns {Promise} Number of successful updates - */ -async function processSyntheticUpdates(github, context, trackedOutputs, temporaryIdMap) { - let updateCount = 0; - - core.info(`\n=== Processing Synthetic Updates ===`); - core.info(`Found ${trackedOutputs.length} output(s) with unresolved temporary IDs`); - - for (const tracked of trackedOutputs) { - // Check if any new temporary IDs were resolved since this output was created - // Only check and update if we have content to check - if (temporaryIdMap.size > tracked.originalTempIdMapSize) { - const contentToCheck = getContentToCheck(tracked.type, tracked.message); - - // Only process if we have content to check - if (contentToCheck !== null && contentToCheck !== "") { - // Check if the content still has unresolved IDs (some may now be resolved) - const stillHasUnresolved = hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap); - const resolvedCount = temporaryIdMap.size - tracked.originalTempIdMapSize; - - if (!stillHasUnresolved) { - // All temporary IDs are now resolved - update the body directly - let logInfo = tracked.result.commentId ? `comment ${tracked.result.commentId} on ${tracked.result.repo}#${tracked.result.itemNumber}` : `${tracked.result.repo}#${tracked.result.number}`; - core.info(`Updating ${tracked.type} ${logInfo} (${resolvedCount} temp ID(s) resolved)`); - - try { - // Replace temporary ID references with resolved values - const updatedContent = replaceTemporaryIdReferences(contentToCheck, temporaryIdMap, tracked.result.repo); - - // Update based on the original type - switch (tracked.type) { - case "create_issue": - await updateIssueBody(github, context, tracked.result.repo, tracked.result.number, updatedContent); - updateCount++; - break; - case "create_discussion": - await updateDiscussionBody(github, context, tracked.result.repo, tracked.result.number, updatedContent); - updateCount++; - break; - case "add_comment": - // Update comment using the tracked comment ID - if (tracked.result.commentId) { - await updateCommentBody(github, context, tracked.result.repo, tracked.result.commentId, updatedContent, tracked.result.isDiscussion); - updateCount++; - } else { - core.debug(`Skipping synthetic update for comment - comment ID not tracked`); - } - break; - default: - core.debug(`Unknown output type: ${tracked.type}`); - } - } catch (error) { - core.warning(`✗ Failed to update ${tracked.type} ${tracked.result.repo}#${tracked.result.number}: ${getErrorMessage(error)}`); - } - } else { - core.debug(`Output ${tracked.result.repo}#${tracked.result.number} still has unresolved temporary IDs`); - } - } - } - } - - if (updateCount > 0) { - core.info(`Completed ${updateCount} synthetic update(s)`); - } else { - core.info(`No synthetic updates needed`); - } - - return updateCount; -} - -/** - * Main entry point for the handler manager - * This is called by the consolidated safe output step - * - * @returns {Promise} - */ -async function main() { - // Detect staged mode before try/finally so it's accessible in the finally block. - // In staged mode (🎭 Staged Mode Preview) no real items are created in GitHub so no manifest should be emitted. - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - try { - core.info("=== Starting Unified Safe Output Handler Manager ==="); - - // Reset create_issue handler's global state to ensure clean state for this run - // This prevents stale data accumulation if the module is reused - const { resetIssuesToAssignCopilot } = require("./create_issue.cjs"); - resetIssuesToAssignCopilot(); - - // Load configuration - const configs = loadConfig(); - core.debug(`Configuration: regular=${JSON.stringify(Object.keys(configs.regular))}, project=${JSON.stringify(Object.keys(configs.project))}`); - - // Setup separate Octokit client for project handlers ONLY if project types are configured - // This avoids unnecessary Octokit instantiation and token validation when not needed - let projectOctokit = null; - if (Object.keys(configs.project).length > 0) { - core.info("Project handler types detected - setting up separate Octokit client"); - projectOctokit = await setupProjectGitHubClient(); - } else { - core.debug("No project handler types configured - skipping project Octokit setup"); - } - - // Load agent output - const agentOutput = loadAgentOutput(); - if (!agentOutput.success) { - core.info("No agent output available - nothing to process"); - // Ensure manifest file exists even when there is no agent output (skip in staged mode) - if (!isStaged) ensureManifestExists(); - // Set empty outputs for downstream steps - core.setOutput("temporary_id_map", "{}"); - core.setOutput("processed_count", 0); - return; - } - - core.info(`Found ${agentOutput.items.length} message(s) in agent output`); - - // Create the shared PR review buffer instance (no global state) - const prReviewBuffer = createReviewBuffer(); - - // Apply footer config with priority: - // 1. submit_pull_request_review.footer (highest priority — footer controls review body) - // 2. Default: "always" - let footerConfig = undefined; - if (configs.regular?.submit_pull_request_review?.footer !== undefined) { - footerConfig = configs.regular.submit_pull_request_review.footer; - core.info(`Using footer config from submit_pull_request_review: ${footerConfig}`); - } - - if (footerConfig !== undefined) { - prReviewBuffer.setFooterMode(footerConfig); - } - - // Load and initialize handlers based on configuration (factory pattern) - // Regular handlers use the global github object, project handlers use the projectOctokit - const messageHandlers = await loadHandlers(configs, projectOctokit, prReviewBuffer); - - if (messageHandlers.size === 0) { - core.info("No handlers loaded - nothing to process"); - // Ensure manifest file exists even when no handlers are loaded (skip in staged mode) - if (!isStaged) ensureManifestExists(); - // Set empty outputs for downstream steps - core.setOutput("temporary_id_map", "{}"); - core.setOutput("processed_count", 0); - return; - } - - // Create manifest logger for recording created items. - // In staged mode, pass null so no items are logged (nothing is actually created). - const logCreatedItem = isStaged ? null : createManifestLogger(); - - // Process all messages in order of appearance - // Pass the projectOctokit so project handlers can use it - const processingResult = await processMessages(messageHandlers, agentOutput.items, projectOctokit, logCreatedItem); - - // Finalize buffered PR review — submit when comments or metadata exist - if (prReviewBuffer.hasBufferedComments() || prReviewBuffer.hasReviewMetadata()) { - core.info(`\n=== Finalizing PR Review ===`); - const bufferedCount = prReviewBuffer.getBufferedCount(); - if (bufferedCount > 0) { - core.info(`Submitting ${bufferedCount} buffered review comment(s) as a single PR review`); - } else { - core.info("Submitting PR review (body-only, no inline comments)"); - } - try { - const reviewResult = await prReviewBuffer.submitReview(); - if (reviewResult.success && !reviewResult.skipped) { - core.info(`✓ PR review submitted successfully: ${reviewResult.review_url}`); - } else if (!reviewResult.success) { - core.warning(`✗ Failed to submit PR review: ${reviewResult.error}`); - } - } catch (reviewError) { - const errorMessage = reviewError instanceof Error ? reviewError.message : String(reviewError); - core.warning(`✗ Exception while submitting PR review: ${errorMessage}`); - } - } - - // Store collected missings in helper module for handlers to access - if (processingResult.missings) { - setCollectedMissings(processingResult.missings); - core.info( - `Stored ${processingResult.missings.missingTools.length} missing tool(s), ${processingResult.missings.missingData.length} missing data item(s), and ${processingResult.missings.noopMessages.length} noop message(s) for footer generation` - ); - } - - // Process synthetic updates by directly updating issue/discussion bodies - let syntheticUpdateCount = 0; - if (processingResult.outputsWithUnresolvedIds && processingResult.outputsWithUnresolvedIds.length > 0) { - // Convert temp ID map back to Map - const temporaryIdMap = new Map(Object.entries(processingResult.temporaryIdMap)); - - syntheticUpdateCount = await processSyntheticUpdates(github, context, processingResult.outputsWithUnresolvedIds, temporaryIdMap); - } - - // Write step summaries for all processed safe-outputs - await writeSafeOutputSummaries(processingResult.results, agentOutput.items); - - // Log summary - const successCount = processingResult.results.filter(r => r.success).length; - const failureCount = processingResult.results.filter(r => !r.success && !r.deferred && !r.skipped).length; - const deferredCount = processingResult.results.filter(r => r.deferred).length; - const skippedStandaloneResults = processingResult.results.filter(r => r.skipped && r.reason === "Handled by standalone step"); - const skippedNoHandlerResults = processingResult.results.filter(r => !r.success && !r.skipped && r.error?.includes("No handler loaded")); - - core.info(`\n=== Processing Summary ===`); - core.info(`Total messages: ${processingResult.results.length}`); - core.info(`Successful: ${successCount}`); - core.info(`Failed: ${failureCount}`); - if (deferredCount > 0) { - core.info(`Deferred: ${deferredCount}`); - } - if (skippedStandaloneResults.length > 0) { - core.info(`Skipped (standalone step): ${skippedStandaloneResults.length}`); - const standaloneTypes = [...new Set(skippedStandaloneResults.map(r => r.type))]; - core.info(` Types: ${standaloneTypes.join(", ")}`); - } - if (skippedNoHandlerResults.length > 0) { - core.warning(`Skipped (no handler): ${skippedNoHandlerResults.length}`); - const noHandlerTypes = [...new Set(skippedNoHandlerResults.map(r => r.type))]; - core.info(` Types: ${noHandlerTypes.join(", ")}`); - } - - // Count different types of temporary IDs in the unified map - const issueIds = Object.values(processingResult.temporaryIdMap).filter(v => v.repo && v.number); - const projectIds = Object.values(processingResult.temporaryIdMap).filter(v => v.projectUrl); - core.info(`Temporary IDs registered: ${Object.keys(processingResult.temporaryIdMap).length} (${issueIds.length} issue/PR, ${projectIds.length} project)`); - core.info(`Synthetic updates: ${syntheticUpdateCount}`); - - if (failureCount > 0) { - core.warning(`${failureCount} message(s) failed to process`); - } - if (skippedNoHandlerResults.length > 0) { - core.warning(`${skippedNoHandlerResults.length} message(s) were skipped because no handler was loaded. Check your workflow's safe-outputs configuration.`); - } - - // Export unified temporary ID map as output for downstream steps - // This map contains both issue/PR references and project URLs - const temporaryIdMapJson = JSON.stringify(processingResult.temporaryIdMap); - core.setOutput("temporary_id_map", temporaryIdMapJson); - core.info(`Exported unified temporary ID map with ${Object.keys(processingResult.temporaryIdMap).length} mapping(s)`); - - // Export processed count for consistency with project handler - core.setOutput("processed_count", successCount); - - // Export issues that need copilot assignment (if any) - const issuesToAssignCopilot = getIssuesToAssignCopilot(); - if (issuesToAssignCopilot.length > 0) { - const issuesToAssignStr = issuesToAssignCopilot.join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssignStr); - core.info(`Exported ${issuesToAssignCopilot.length} issue(s) for copilot assignment: ${issuesToAssignStr}`); - } else { - core.setOutput("issues_to_assign_copilot", ""); - } - - // Emit individual named outputs for the first successful result of each safe output type. - // These outputs enable workflow_call callers to access specific results. - emitSafeOutputActionOutputs(processingResult); - - // Ensure the manifest file always exists for artifact upload (even if no items were created). - // Skip in staged mode — no real items were created so no manifest should be emitted. - // Note: createManifestLogger() also calls ensureManifestExists() when the logger is created, - // so this is a safety net for cases where we never reached the logger creation. - if (!isStaged) ensureManifestExists(); - - core.info("=== Unified Safe Output Handler Manager Completed ==="); - } catch (error) { - core.setFailed(`${ERR_VALIDATION}: Handler manager failed: ${getErrorMessage(error)}`); - } finally { - // Guarantee the manifest file exists for artifact upload even when the handler fails. - // This is a no-op if the file was already created by createManifestLogger(). - if (!isStaged) { - try { - ensureManifestExists(); - } catch (_e) { - // Ignore errors here — we must not mask the original failure - } - } - } -} - -module.exports = { main, loadConfig, loadHandlers, processMessages, setupProjectGitHubClient }; - -// Run main if this script is executed directly (not required as a module) -if (require.main === module) { - main(); -} diff --git a/actions/setup/js/safe_output_unified_handler_manager.test.cjs b/actions/setup/js/safe_output_unified_handler_manager.test.cjs deleted file mode 100644 index b2a6c27b4b..0000000000 --- a/actions/setup/js/safe_output_unified_handler_manager.test.cjs +++ /dev/null @@ -1,396 +0,0 @@ -// @ts-check - -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { loadConfig, setupProjectGitHubClient, processMessages } from "./safe_output_unified_handler_manager.cjs"; -import { resolveIssueNumber } from "./temporary_id.cjs"; - -// Mock @actions/github -vi.mock("@actions/github", () => ({ - getOctokit: vi.fn(() => ({ - graphql: vi.fn(), - request: vi.fn(), - rest: {}, - })), -})); - -describe("Unified Safe Output Handler Manager", () => { - beforeEach(() => { - // Mock global core - global.core = { - info: vi.fn(), - debug: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setOutput: vi.fn(), - setFailed: vi.fn(), - }; - - // Mock global context - global.context = { - repo: { - owner: "testowner", - repo: "testrepo", - }, - payload: {}, - }; - - // Clean up environment variables - delete process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG; - delete process.env.GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG; - delete process.env.GH_AW_PROJECT_GITHUB_TOKEN; - delete process.env.GH_AW_TEMPORARY_ID_MAP; - }); - - describe("processMessages chaining scenarios", () => { - it("processes dependencies first even if messages are out of order", async () => { - const createIssueHandler = vi.fn(async message => { - // Should be normalized by the dispatcher before handler invocation. - expect(message.temporary_id).toBe("aw_deadbe"); - return { repo: "testowner/testrepo", number: 123, temporaryId: message.temporary_id }; - }); - - const addCommentHandler = vi.fn(async (_message, resolvedTemporaryIds) => { - expect(resolvedTemporaryIds).toHaveProperty("aw_deadbe"); - expect(resolvedTemporaryIds.aw_deadbe).toEqual({ repo: "testowner/testrepo", number: 123 }); - return { ok: true }; - }); - - const handlers = new Map([ - ["create_issue", createIssueHandler], - ["add_comment", addCommentHandler], - ]); - - // Intentionally out-of-order: comment references temp ID created by create_issue. - const messages = [ - { - type: "add_comment", - repo: "testowner/testrepo", - issue_number: "#aw_deadbe", - body: "See #aw_deadbe for details", - }, - { - type: "create_issue", - repo: "testowner/testrepo", - title: "Create first", - body: "Body", - temporary_id: "aw_deadbe", - }, - ]; - - const result = await processMessages(handlers, messages); - - expect(result.success).toBe(true); - expect(createIssueHandler).toHaveBeenCalledTimes(1); - expect(addCommentHandler).toHaveBeenCalledTimes(1); - expect(result.temporaryIdMap).toHaveProperty("aw_deadbe"); - expect(result.temporaryIdMap.aw_deadbe).toEqual({ repo: "testowner/testrepo", number: 123 }); - }); - - it("normalizes temporaryId camelCase + # prefix + casing to strict lowercase", async () => { - const createIssueHandler = vi.fn(async message => { - expect(message.temporary_id).toBe("aw_abc123"); - return { repo: "testowner/testrepo", number: 321, temporaryId: message.temporary_id }; - }); - - const handlers = new Map([["create_issue", createIssueHandler]]); - const messages = [ - { - type: "create_issue", - repo: "testowner/testrepo", - title: "Normalize", - body: "Body", - temporaryId: " #AW_ABC123 ", - }, - ]; - - const result = await processMessages(handlers, messages); - - expect(result.success).toBe(true); - expect(createIssueHandler).toHaveBeenCalledTimes(1); - expect(result.temporaryIdMap).toHaveProperty("aw_abc123"); - expect(result.temporaryIdMap.aw_abc123).toEqual({ repo: "testowner/testrepo", number: 321 }); - }); - - it("rejects malformed temporary_id before invoking handler", async () => { - const createIssueHandler = vi.fn(async () => ({ repo: "testowner/testrepo", number: 1, temporaryId: "aw_deadbe" })); - - const handlers = new Map([["create_issue", createIssueHandler]]); - const messages = [ - { - type: "create_issue", - repo: "testowner/testrepo", - title: "Bad temp ID", - body: "Body", - temporary_id: "aw_bundle_npm001_invalid", - }, - ]; - - const result = await processMessages(handlers, messages); - - expect(createIssueHandler).not.toHaveBeenCalled(); - expect(result.results).toHaveLength(1); - expect(result.results[0].success).toBe(false); - expect(result.results[0].error).toMatch(/invalid temporary_id/i); - }); - - it("chains update_project draft mapping across multiple messages", async () => { - const updateProjectHandler = vi.fn(async (message, temporaryIdMap) => { - if (message.operation === "create") { - expect(temporaryIdMap.size).toBe(0); - return { temporaryId: "aw_deadbe", draftItemId: "draft-item-1" }; - } - - expect(temporaryIdMap.get("aw_deadbe")).toEqual({ draftItemId: "draft-item-1" }); - return { ok: true }; - }); - - const handlers = new Map([["update_project", updateProjectHandler]]); - const messages = [ - { type: "update_project", operation: "create", temporary_id: "aw_deadbe" }, - { type: "update_project", operation: "update", draft_issue_id: "#aw_deadbe" }, - ]; - - const result = await processMessages(handlers, messages, {}); - - expect(result.success).toBe(true); - expect(updateProjectHandler).toHaveBeenCalledTimes(2); - expect(result.temporaryIdMap).toHaveProperty("aw_deadbe"); - expect(result.temporaryIdMap.aw_deadbe).toEqual({ draftItemId: "draft-item-1" }); - }); - - it("chains create_issue → update_project via content_number temporary ID", async () => { - const createIssueHandler = vi.fn(async message => { - expect(message.temporary_id).toBe("aw_deadbe"); - return { repo: "testowner/testrepo", number: 456, temporaryId: message.temporary_id }; - }); - - const updateProjectHandler = vi.fn(async (message, temporaryIdMap) => { - // Ensure the mapping exists in the live Map for project handlers. - expect(temporaryIdMap.get("aw_deadbe")).toEqual({ repo: "testowner/testrepo", number: 456 }); - - // And validate that project handlers can resolve it using shared helper. - const resolved = resolveIssueNumber(message.content_number, temporaryIdMap); - expect(resolved.errorMessage).toBeNull(); - expect(resolved.wasTemporaryId).toBe(true); - expect(resolved.resolved).toEqual({ repo: "testowner/testrepo", number: 456 }); - - return { ok: true }; - }); - - const handlers = new Map([ - ["create_issue", createIssueHandler], - ["update_project", updateProjectHandler], - ]); - - // Intentionally out-of-order: update_project references temp ID created by create_issue. - const messages = [ - { - type: "update_project", - project: "https://github.com/orgs/testowner/projects/60", - content_type: "issue", - content_number: "#aw_deadbe", - }, - { - type: "create_issue", - repo: "testowner/testrepo", - title: "Create an issue", - body: "Body", - temporary_id: "aw_deadbe", - }, - ]; - - const result = await processMessages(handlers, messages, {}); - - expect(result.success).toBe(true); - expect(createIssueHandler).toHaveBeenCalledTimes(1); - expect(updateProjectHandler).toHaveBeenCalledTimes(1); - expect(result.temporaryIdMap.aw_deadbe).toEqual({ repo: "testowner/testrepo", number: 456 }); - }); - }); - - describe("loadConfig", () => { - it("should load regular handler config", () => { - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - create_issue: { max: 5 }, - add_comment: {}, - }); - - const config = loadConfig(); - - expect(config).toHaveProperty("regular"); - expect(config).toHaveProperty("project"); - expect(config.regular).toHaveProperty("create_issue"); - expect(config.regular.create_issue).toEqual({ max: 5 }); - expect(config.regular).toHaveProperty("add_comment"); - }); - - it("should load project handler config", () => { - process.env.GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG = JSON.stringify({ - create_project: { max: 1 }, - update_project: { max: 100 }, - }); - - const config = loadConfig(); - - expect(config).toHaveProperty("project"); - expect(config.project).toHaveProperty("create_project"); - expect(config.project.create_project).toEqual({ max: 1 }); - expect(config.project).toHaveProperty("update_project"); - }); - - it("should load both regular and project configs", () => { - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - create_issue: { max: 5 }, - }); - process.env.GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG = JSON.stringify({ - create_project: { max: 1 }, - }); - - const config = loadConfig(); - - expect(config.regular).toHaveProperty("create_issue"); - expect(config.project).toHaveProperty("create_project"); - }); - - it("should throw error if no config is provided", () => { - expect(() => loadConfig()).toThrow(/At least one of .* is required/); - }); - - it("should normalize hyphenated keys to underscores", () => { - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - "create-issue": { max: 5 }, - }); - - const config = loadConfig(); - - expect(config.regular).toHaveProperty("create_issue"); - expect(config.regular).not.toHaveProperty("create-issue"); - }); - - it("should automatically split project handlers from unified config", () => { - // Simulate Go compiler putting all handlers in one config - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - create_issue: { max: 5 }, - add_comment: {}, - update_project: { max: 20, project: "https://github.com/orgs/test/projects/1" }, - create_project: { max: 1 }, - create_project_status_update: { max: 1, project: "https://github.com/orgs/test/projects/1" }, - }); - - const config = loadConfig(); - - // Regular handlers should stay in regular config - expect(config.regular).toHaveProperty("create_issue"); - expect(config.regular).toHaveProperty("add_comment"); - expect(config.regular).not.toHaveProperty("update_project"); - expect(config.regular).not.toHaveProperty("create_project"); - expect(config.regular).not.toHaveProperty("create_project_status_update"); - - // Project handlers should be moved to project config - expect(config.project).toHaveProperty("update_project"); - expect(config.project).toHaveProperty("create_project"); - expect(config.project).toHaveProperty("create_project_status_update"); - expect(config.project.update_project).toEqual({ max: 20, project: "https://github.com/orgs/test/projects/1" }); - }); - - it("should handle hyphenated project handler names and split correctly", () => { - // Test with hyphenated names (common in YAML/JSON configs) - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - "create-issue": { max: 5 }, - "update-project": { max: 20 }, - "create-project-status-update": { max: 1 }, - }); - - const config = loadConfig(); - - // Check normalization and splitting - expect(config.regular).toHaveProperty("create_issue"); - expect(config.regular).not.toHaveProperty("update_project"); - expect(config.project).toHaveProperty("update_project"); - expect(config.project).toHaveProperty("create_project_status_update"); - }); - - it("should prioritize explicit project config over auto-split handlers", () => { - // Both configs provided - explicit project config should take precedence - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - create_issue: { max: 5 }, - update_project: { max: 20, project: "url1" }, - }); - process.env.GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG = JSON.stringify({ - update_project: { max: 50, project: "url2" }, // Should override auto-split config - create_project: { max: 1 }, - }); - - const config = loadConfig(); - - expect(config.regular).toHaveProperty("create_issue"); - expect(config.project).toHaveProperty("update_project"); - expect(config.project).toHaveProperty("create_project"); - // Explicit config should take precedence - expect(config.project.update_project).toEqual({ max: 50, project: "url2" }); - }); - - it("should handle smoke-project workflow scenario correctly", () => { - // Simulate the exact config from smoke-project workflow - // where Go compiler puts all handlers (including project handlers) in one config - process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify({ - add_comment: { hide_older_comments: true, max: 2 }, - add_labels: { allowed: ["smoke-project"], "target-repo": "github-agentic-workflows/demo-repository" }, - create_issue: { close_older_issues: true, expires: 2, group: true, max: 1, "target-repo": "github-agentic-workflows/demo-repository" }, - create_project_status_update: { "github-token": "***", max: 1, project: "https://github.com/orgs/github-agentic-workflows/projects/1" }, - missing_data: {}, - missing_tool: {}, - remove_labels: { allowed: ["smoke-project"], "target-repo": "github-agentic-workflows/demo-repository" }, - update_project: { - "github-token": "***", - max: 20, - project: "https://github.com/orgs/github-agentic-workflows/projects/1", - views: [ - { name: "Smoke Test Board", layout: "board", filter: "is:open" }, - { name: "Smoke Test Table", layout: "table" }, - ], - }, - }); - - const config = loadConfig(); - - // Regular handlers should stay in regular config - expect(config.regular).toHaveProperty("add_comment"); - expect(config.regular).toHaveProperty("add_labels"); - expect(config.regular).toHaveProperty("create_issue"); - expect(config.regular).toHaveProperty("missing_data"); - expect(config.regular).toHaveProperty("missing_tool"); - expect(config.regular).toHaveProperty("remove_labels"); - - // Project handlers should be automatically moved to project config - expect(config.project).toHaveProperty("update_project"); - expect(config.project).toHaveProperty("create_project_status_update"); - - // Regular handlers should NOT contain project handlers - expect(config.regular).not.toHaveProperty("update_project"); - expect(config.regular).not.toHaveProperty("create_project_status_update"); - - // Verify project handler configs are intact - expect(config.project.update_project).toHaveProperty("max", 20); - expect(config.project.update_project).toHaveProperty("project"); - expect(config.project.update_project).toHaveProperty("views"); - expect(config.project.create_project_status_update).toHaveProperty("max", 1); - }); - }); - - describe("setupProjectGitHubClient", () => { - it("should throw error if GH_AW_PROJECT_GITHUB_TOKEN is not set", async () => { - await expect(setupProjectGitHubClient()).rejects.toThrow(/GH_AW_PROJECT_GITHUB_TOKEN environment variable is required/); - }); - - it("should create Octokit instance when token is provided", async () => { - process.env.GH_AW_PROJECT_GITHUB_TOKEN = "test-project-token"; - - const octokit = await setupProjectGitHubClient(); - - expect(octokit).toBeDefined(); - expect(octokit).toHaveProperty("graphql"); - expect(octokit).toHaveProperty("request"); - }); - }); -}); diff --git a/pkg/workflow/cjs_require_validation_test.go b/pkg/workflow/cjs_require_validation_test.go index 893fd5a259..8e949f859e 100644 --- a/pkg/workflow/cjs_require_validation_test.go +++ b/pkg/workflow/cjs_require_validation_test.go @@ -86,11 +86,7 @@ func TestCJSFilesNoActionsRequires(t *testing.T) { var failedFiles []string var violations []string - // Exception: safe_output_unified_handler_manager.cjs is allowed to require @actions/github - // because the package is installed at runtime via setup.sh when safe-output-projects flag is enabled - allowedNpmActionsRequires := map[string][]string{ - "safe_output_unified_handler_manager.cjs": {"@actions/github"}, - } + allowedNpmActionsRequires := map[string][]string{} for _, filename := range cjsFiles { filepath := filepath.Join(cjsDir, filename)